Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6276e97c4c | |||
| 09142ce2d4 | |||
| 90bc2262ec | |||
| 9594449215 | |||
| 6187c9f438 | |||
| b28e815545 | |||
| 17be73bbe8 | |||
| cd35f38db1 | |||
| 3c752d581d | |||
| 1f20a48a20 | |||
| 3a585c1f43 | |||
| 5ac5b93ac4 | |||
| b484f0bfb6 | |||
| 0aeb8b0f88 | |||
| 8e7357860c | |||
| 936de8c996 | |||
| e986be9db5 | |||
| 876d517f3d | |||
| 0a12c68274 | |||
| 873e537301 | |||
| 94c1a47641 | |||
| d0296ab012 | |||
| 2fd944d88e | |||
| fc98c4c1c4 | |||
| 8bf6cbb56e | |||
| b97f97ca5e | |||
| 0c7bcdcf90 | |||
| 840122e603 | |||
| 8518d10c6d | |||
| f214be28a9 | |||
| f219d9d1a0 | |||
| 8797c0d917 | |||
| 0121d05dff | |||
| 26faf62657 | |||
| 61e79d067a | |||
| 103838d1b8 | |||
| 47de2a5251 | |||
| d4d8f767e3 | |||
| c75da619cf | |||
| aea6f5efaa | |||
| 2625b8f4db | |||
| e6460513ea | |||
| a2504f2726 | |||
| 23f445ce4d | |||
| 61fe0c6ebb | |||
| 72c4335386 | |||
| 640f0b92f0 | |||
| c0f8db29c0 | |||
| 285cd92514 | |||
| a5293a5b39 | |||
| 8430a7e8ce | |||
| 34e430713b | |||
| d23a5f8d62 | |||
| 9f89944bc5 | |||
| af1ab75c87 | |||
| e563ba2f99 | |||
| ea7796459a | |||
| 1dfce31580 | |||
| 5eb08dcca3 | |||
| 8565485253 | |||
| cc744a20c7 | |||
| c43b10d38d | |||
| ed61a41267 | |||
| 58d1695cba | |||
| 6bdfc917e3 | |||
| af739b0efb | |||
| cbaf1cf630 | |||
| 6f203c94b0 | |||
| 81852fb94c | |||
| d0028620e6 | |||
| 0e021f27fe | |||
| bd0996d39b | |||
| 0c383da201 | |||
| 8a7b9d6d7b | |||
| d3b9a68443 | |||
| 765383157d | |||
| 1f8ee401fa | |||
| 2200c4169d | |||
| cd485a0464 |
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2018] [Syed Haris Ali]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -9,7 +9,7 @@ EXTERNAL SOURCES:
|
||||
:path: "../"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
SwiftAudioPlayer: 6d47a258720feecb431509eb71215559621fa12d
|
||||
SwiftAudioPlayer: abfeb4ac2467cdd7b5b8a5cb442780184ea172bc
|
||||
|
||||
PODFILE CHECKSUM: 84ea27746bf895da86125356a8d0df7a323c4c08
|
||||
|
||||
|
||||
+6
-4
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "SwiftAudioPlayer",
|
||||
"version": "0.1.0",
|
||||
"summary": "A short description of SwiftAudioPlayer.",
|
||||
"description": "TODO: Add long description of the pod here.",
|
||||
"summary": "SwiftAudioPlayer is a Swift based audio player that can handle streaming from a remote location and audio manipulation.",
|
||||
"description": "SwiftAudioPlayer is a Swift based audio player that can handle streaming from a remote location and audio manipulation. It can perform actions like playing audio up to 32x playback rate on streamed audio.",
|
||||
"homepage": "https://github.com/tanhakabir/SwiftAudioPlayer",
|
||||
"license": {
|
||||
"type": "MIT",
|
||||
"file": "LICENSE"
|
||||
},
|
||||
"authors": {
|
||||
"tanhakabir": "tanhakabir.ca@gmail.com"
|
||||
"tanhakabir": "tanhakabir.ca@gmail.com",
|
||||
"JonMercer": "mercer.jon@gmail.com"
|
||||
},
|
||||
"source": {
|
||||
"git": "https://github.com/tanhakabir/SwiftAudioPlayer.git",
|
||||
@@ -18,5 +19,6 @@
|
||||
"platforms": {
|
||||
"ios": "8.0"
|
||||
},
|
||||
"source_files": "SwiftAudioPlayer/Classes/**/*"
|
||||
"source_files": "SwiftAudioPlayer/Classes/**/*",
|
||||
"swift_version": "4.0"
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ EXTERNAL SOURCES:
|
||||
:path: "../"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
SwiftAudioPlayer: 6d47a258720feecb431509eb71215559621fa12d
|
||||
SwiftAudioPlayer: abfeb4ac2467cdd7b5b8a5cb442780184ea172bc
|
||||
|
||||
PODFILE CHECKSUM: 84ea27746bf895da86125356a8d0df7a323c4c08
|
||||
|
||||
|
||||
+227
-17
@@ -7,7 +7,6 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1606D9AEB876D4A642C3CE2985C600C9 /* ReplaceMe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3EB275E4B4A4F3D26D5B3835EA81466 /* ReplaceMe.swift */; };
|
||||
27E3EC64A90305ACA68AE35A7DC597E0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A16F4CFC63FAC439D7A04994F579A03 /* Foundation.framework */; };
|
||||
2A421C2A94DF56A00FF73322C6B470C8 /* SwiftAudioPlayer-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 0E268C8D5FBBF7E0E790D3AA6A70FEC2 /* SwiftAudioPlayer-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
3A31FEF49CC8C3B757EEB4EBCC9BCCF4 /* Pods-SwiftAudioPlayer_Tests-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 351771425C270B04BF2A07F0262DA192 /* Pods-SwiftAudioPlayer_Tests-dummy.m */; };
|
||||
@@ -15,6 +14,40 @@
|
||||
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, ); }; };
|
||||
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 */; };
|
||||
A4681FCE220113A20018AB51 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FB62200FE090018AB51 /* AudioConverter.swift */; };
|
||||
A4681FCF220113A40018AB51 /* AudioConverterListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FBA2201002F0018AB51 /* AudioConverterListener.swift */; };
|
||||
A4681FD0220113A70018AB51 /* AudioConverterErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FB82200FE7F0018AB51 /* AudioConverterErrors.swift */; };
|
||||
A4681FD1220113AF0018AB51 /* AudioParsable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FAB2200F8490018AB51 /* AudioParsable.swift */; };
|
||||
A4681FD2220113B20018AB51 /* AudioParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FAD2200F8E90018AB51 /* AudioParser.swift */; };
|
||||
A4681FD3220113B60018AB51 /* AudioParserPropertyListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FB12200FB020018AB51 /* AudioParserPropertyListener.swift */; };
|
||||
A4681FD4220113BA0018AB51 /* AudioParserPacketListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FB32200FC520018AB51 /* AudioParserPacketListener.swift */; };
|
||||
A4681FD5220113BD0018AB51 /* AudioParserErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FAF2200FA6C0018AB51 /* AudioParserErrors.swift */; };
|
||||
A4681FD6220113BF0018AB51 /* AudioThrottler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FA82200F5A20018AB51 /* AudioThrottler.swift */; };
|
||||
A4681FD7220113C30018AB51 /* StreamProgressPTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FA62200F0130018AB51 /* StreamProgressPTO.swift */; };
|
||||
A4681FD8220113C60018AB51 /* AudioDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F992200E3D90018AB51 /* AudioDataManager.swift */; };
|
||||
A4681FD9220113CD0018AB51 /* AudioStreamWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F9C2200E4B40018AB51 /* AudioStreamWorker.swift */; };
|
||||
A4681FDA220113D00018AB51 /* StreamProgressDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F9E2200E5DE0018AB51 /* StreamProgressDTO.swift */; };
|
||||
A4681FDB220113D40018AB51 /* FileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FA42200E7920018AB51 /* FileStorage.swift */; };
|
||||
A4681FDC220113D70018AB51 /* AudioDownloadWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FA22200E6710018AB51 /* AudioDownloadWorker.swift */; };
|
||||
A4681FDD220113DC0018AB51 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F962200E2E20018AB51 /* URL.swift */; };
|
||||
A4681FDE220113DE0018AB51 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F892200DB3C0018AB51 /* Date.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
B73D01578ABBDB6FF402D868A6C547FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A16F4CFC63FAC439D7A04994F579A03 /* Foundation.framework */; };
|
||||
E08AD6157EF688FE832F866CBCDA3532 /* SwiftAudioPlayer-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = FB83B3B4253D41C37C5563D34D450BF8 /* SwiftAudioPlayer-dummy.m */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -41,7 +74,7 @@
|
||||
030E0D4C0BE29E2606B0BCB65B9BBC42 /* Pods-SwiftAudioPlayer_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-SwiftAudioPlayer_Tests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
0B3AF0F1A1DF1101E93137959D2E5F24 /* Pods-SwiftAudioPlayer_Example-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-SwiftAudioPlayer_Example-acknowledgements.markdown"; sourceTree = "<group>"; };
|
||||
0E268C8D5FBBF7E0E790D3AA6A70FEC2 /* SwiftAudioPlayer-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SwiftAudioPlayer-umbrella.h"; sourceTree = "<group>"; };
|
||||
15DF3E7F1B5E10B1BBE49D3E9A67C938 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; path = LICENSE; sourceTree = "<group>"; };
|
||||
15DF3E7F1B5E10B1BBE49D3E9A67C938 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
|
||||
16FCEC9685DAD30C0013E9ECD938611E /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
305774DC5582C6E1BA1511DED1ECB225 /* Pods-SwiftAudioPlayer_Tests-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-SwiftAudioPlayer_Tests-frameworks.sh"; sourceTree = "<group>"; };
|
||||
314D056517A7B04FFAFF279157B7ADBB /* Pods-SwiftAudioPlayer_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-SwiftAudioPlayer_Tests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -49,28 +82,62 @@
|
||||
3B0B76CB1439F4D361322144E5A65C3A /* Pods-SwiftAudioPlayer_Example-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-SwiftAudioPlayer_Example-umbrella.h"; sourceTree = "<group>"; };
|
||||
3F45E3A0690F048214FCE84887950057 /* SwiftAudioPlayer-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SwiftAudioPlayer-prefix.pch"; sourceTree = "<group>"; };
|
||||
41C6A056512760933DE244855EF94DF0 /* Pods-SwiftAudioPlayer_Tests.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-SwiftAudioPlayer_Tests.modulemap"; sourceTree = "<group>"; };
|
||||
509D93CD81F074F6E7C4B9DE13210ACF /* Pods_SwiftAudioPlayer_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = Pods_SwiftAudioPlayer_Example.framework; path = "Pods-SwiftAudioPlayer_Example.framework"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
509D93CD81F074F6E7C4B9DE13210ACF /* Pods_SwiftAudioPlayer_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SwiftAudioPlayer_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
50C71346CE708A211A5AFAC20BAE48CB /* Pods-SwiftAudioPlayer_Tests-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-SwiftAudioPlayer_Tests-umbrella.h"; sourceTree = "<group>"; };
|
||||
55AB0CDF00C23619C7F54FE21D0C9534 /* Pods-SwiftAudioPlayer_Example-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-SwiftAudioPlayer_Example-frameworks.sh"; sourceTree = "<group>"; };
|
||||
5A16F4CFC63FAC439D7A04994F579A03 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
69AF5444212FEC2674325627F26305AD /* Pods-SwiftAudioPlayer_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-SwiftAudioPlayer_Example.release.xcconfig"; sourceTree = "<group>"; };
|
||||
6EC04ECC8F7CB2AF2E4E042A6A8ECFA1 /* SwiftAudioPlayer.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; lastKnownFileType = text; path = SwiftAudioPlayer.podspec; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
|
||||
6EC04ECC8F7CB2AF2E4E042A6A8ECFA1 /* SwiftAudioPlayer.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; path = SwiftAudioPlayer.podspec; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
|
||||
70839C5AD428953FAF3091E814FF6E31 /* Pods-SwiftAudioPlayer_Example.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-SwiftAudioPlayer_Example.modulemap"; sourceTree = "<group>"; };
|
||||
782193D2A4B5EA65A5A468B871418969 /* SwiftAudioPlayer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = SwiftAudioPlayer.framework; path = SwiftAudioPlayer.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
782193D2A4B5EA65A5A468B871418969 /* SwiftAudioPlayer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftAudioPlayer.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8F12318E3F0F591F1C2ACAE6F204F753 /* Pods-SwiftAudioPlayer_Tests-resources.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-SwiftAudioPlayer_Tests-resources.sh"; sourceTree = "<group>"; };
|
||||
93A4A3777CF96A4AAC1D13BA6DCCEA73 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; lastKnownFileType = text; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
|
||||
93A4A3777CF96A4AAC1D13BA6DCCEA73 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
|
||||
9622E16E03B20FC0C41123BA8A50C1F0 /* Pods-SwiftAudioPlayer_Tests-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-SwiftAudioPlayer_Tests-acknowledgements.plist"; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
A3EB275E4B4A4F3D26D5B3835EA81466 /* ReplaceMe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReplaceMe.swift; path = SwiftAudioPlayer/Classes/ReplaceMe.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>"; };
|
||||
A4681F8D2200E00E0018AB51 /* SAPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayer.swift; sourceTree = "<group>"; };
|
||||
A4681F8F2200E1450018AB51 /* SAPlayerPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayerPresenter.swift; sourceTree = "<group>"; };
|
||||
A4681F912200E1950018AB51 /* SAPlayerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayerDelegate.swift; sourceTree = "<group>"; };
|
||||
A4681F942200E2220018AB51 /* AudioDiskEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDiskEngine.swift; sourceTree = "<group>"; };
|
||||
A4681F962200E2E20018AB51 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
|
||||
A4681F992200E3D90018AB51 /* AudioDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AudioDataManager.swift; path = ../../Source/Model/AudioDataManager.swift; sourceTree = "<group>"; };
|
||||
A4681F9C2200E4B40018AB51 /* AudioStreamWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AudioStreamWorker.swift; path = ../../Model/Streaming/AudioStreamWorker.swift; sourceTree = "<group>"; };
|
||||
A4681F9E2200E5DE0018AB51 /* StreamProgressDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamProgressDTO.swift; path = ../../Model/Streaming/StreamProgressDTO.swift; sourceTree = "<group>"; };
|
||||
A4681FA22200E6710018AB51 /* AudioDownloadWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AudioDownloadWorker.swift; path = ../../Model/Downloading/AudioDownloadWorker.swift; sourceTree = "<group>"; };
|
||||
A4681FA42200E7920018AB51 /* FileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileStorage.swift; path = ../../Model/Downloading/FileStorage.swift; sourceTree = "<group>"; };
|
||||
A4681FA62200F0130018AB51 /* StreamProgressPTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamProgressPTO.swift; path = ../Model/StreamProgressPTO.swift; sourceTree = "<group>"; };
|
||||
A4681FA82200F5A20018AB51 /* AudioThrottler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioThrottler.swift; sourceTree = "<group>"; };
|
||||
A4681FAB2200F8490018AB51 /* AudioParsable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioParsable.swift; sourceTree = "<group>"; };
|
||||
A4681FAD2200F8E90018AB51 /* AudioParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioParser.swift; sourceTree = "<group>"; };
|
||||
A4681FAF2200FA6C0018AB51 /* AudioParserErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioParserErrors.swift; sourceTree = "<group>"; };
|
||||
A4681FB12200FB020018AB51 /* AudioParserPropertyListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioParserPropertyListener.swift; sourceTree = "<group>"; };
|
||||
A4681FB32200FC520018AB51 /* AudioParserPacketListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioParserPacketListener.swift; sourceTree = "<group>"; };
|
||||
A4681FB62200FE090018AB51 /* AudioConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverter.swift; sourceTree = "<group>"; };
|
||||
A4681FB82200FE7F0018AB51 /* AudioConverterErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterErrors.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
A4FBA6B8221BAF8700D5A353 /* SAAudioAvailabilityRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAAudioAvailabilityRange.swift; sourceTree = "<group>"; };
|
||||
AB41D88A2C694FBDF26EA56381EED25F /* Pods-SwiftAudioPlayer_Example-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Pods-SwiftAudioPlayer_Example-dummy.m"; sourceTree = "<group>"; };
|
||||
B8C829A46249957CD3056074B5CC0BBB /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; path = README.md; sourceTree = "<group>"; };
|
||||
B8C829A46249957CD3056074B5CC0BBB /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
|
||||
B9A6DFC8AB64B139080060EA639B3A7D /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BCAD67E3D7744FEFA5B221BDA7B25B20 /* SwiftAudioPlayer.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftAudioPlayer.xcconfig; sourceTree = "<group>"; };
|
||||
BF5B667B9103284C373811A04411C7C1 /* Pods-SwiftAudioPlayer_Example-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-SwiftAudioPlayer_Example-acknowledgements.plist"; sourceTree = "<group>"; };
|
||||
E1C110BCB4A9F826B59DC6905BAB3C6E /* Pods-SwiftAudioPlayer_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-SwiftAudioPlayer_Example.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
F3E4AB96148429D903B6E5DAEB19C4C1 /* Pods_SwiftAudioPlayer_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = Pods_SwiftAudioPlayer_Tests.framework; path = "Pods-SwiftAudioPlayer_Tests.framework"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F3E4AB96148429D903B6E5DAEB19C4C1 /* Pods_SwiftAudioPlayer_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SwiftAudioPlayer_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FB83B3B4253D41C37C5563D34D450BF8 /* SwiftAudioPlayer-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "SwiftAudioPlayer-dummy.m"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -115,7 +182,7 @@
|
||||
41C7F403DA52FBC5C40644BB0E824CAA /* SwiftAudioPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A3EB275E4B4A4F3D26D5B3835EA81466 /* ReplaceMe.swift */,
|
||||
A4681FE2220117B50018AB51 /* Source */,
|
||||
840F8E752B4437107D761C28D4EE8D0B /* Pod */,
|
||||
EAE1BCB45D8F275CE4428674B5151284 /* Support Files */,
|
||||
);
|
||||
@@ -193,10 +260,108 @@
|
||||
15DF3E7F1B5E10B1BBE49D3E9A67C938 /* LICENSE */,
|
||||
B8C829A46249957CD3056074B5CC0BBB /* README.md */,
|
||||
6EC04ECC8F7CB2AF2E4E042A6A8ECFA1 /* SwiftAudioPlayer.podspec */,
|
||||
A4523BC8220A0B3C0079C4BC /* Credited_LICENSE */,
|
||||
);
|
||||
name = Pod;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681F842200D91D0018AB51 /* Util */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4681F8B2200DDD50018AB51 /* Constants.swift */,
|
||||
A4681F802200D0500018AB51 /* Log.swift */,
|
||||
A4681F872200DAD50018AB51 /* DirectorThreadSafeClosures.swift */,
|
||||
A4681F892200DB3C0018AB51 /* Date.swift */,
|
||||
A4681F962200E2E20018AB51 /* URL.swift */,
|
||||
);
|
||||
path = Util;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681F932200E2020018AB51 /* Engine */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A41AA0D1238BB9B600A467E1 /* SAPlayingStatus.swift */,
|
||||
A4FBA6B8221BAF8700D5A353 /* SAAudioAvailabilityRange.swift */,
|
||||
A4681F822200D9150018AB51 /* AudioEngine.swift */,
|
||||
A4681F942200E2220018AB51 /* AudioDiskEngine.swift */,
|
||||
A4681FBC220100AB0018AB51 /* AudioStreamEngine.swift */,
|
||||
A4681FB52200FDF30018AB51 /* Converter */,
|
||||
A4681FAA2200F8280018AB51 /* Parser */,
|
||||
A4681FA82200F5A20018AB51 /* AudioThrottler.swift */,
|
||||
);
|
||||
path = Engine;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681F9B2200E4850018AB51 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4681F992200E3D90018AB51 /* AudioDataManager.swift */,
|
||||
A4681FA62200F0130018AB51 /* StreamProgressPTO.swift */,
|
||||
A4681FA02200E5F50018AB51 /* Streaming */,
|
||||
A4681FA12200E6540018AB51 /* Downloading */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681FA02200E5F50018AB51 /* Streaming */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4681F9E2200E5DE0018AB51 /* StreamProgressDTO.swift */,
|
||||
A4681F9C2200E4B40018AB51 /* AudioStreamWorker.swift */,
|
||||
);
|
||||
path = Streaming;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681FA12200E6540018AB51 /* Downloading */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4681FA22200E6710018AB51 /* AudioDownloadWorker.swift */,
|
||||
A4681FA42200E7920018AB51 /* FileStorage.swift */,
|
||||
);
|
||||
path = Downloading;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681FAA2200F8280018AB51 /* Parser */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4681FAB2200F8490018AB51 /* AudioParsable.swift */,
|
||||
A4681FAD2200F8E90018AB51 /* AudioParser.swift */,
|
||||
A4681FB12200FB020018AB51 /* AudioParserPropertyListener.swift */,
|
||||
A4681FB32200FC520018AB51 /* AudioParserPacketListener.swift */,
|
||||
A4681FAF2200FA6C0018AB51 /* AudioParserErrors.swift */,
|
||||
);
|
||||
path = Parser;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681FB52200FDF30018AB51 /* Converter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4681FB62200FE090018AB51 /* AudioConverter.swift */,
|
||||
A4681FBA2201002F0018AB51 /* AudioConverterListener.swift */,
|
||||
A4681FB82200FE7F0018AB51 /* AudioConverterErrors.swift */,
|
||||
);
|
||||
path = Converter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4681FE2220117B50018AB51 /* Source */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4FBA6B3221B74C900D5A353 /* SALockScreenInfo.swift */,
|
||||
A4681F8D2200E00E0018AB51 /* SAPlayer.swift */,
|
||||
A4FBA6B6221BAC3D00D5A353 /* SAPlayerUpdateSubscription.swift */,
|
||||
A4B4CC112223ED2A0045554B /* SAPlayerDownloader.swift */,
|
||||
A4681F912200E1950018AB51 /* SAPlayerDelegate.swift */,
|
||||
A4681F8F2200E1450018AB51 /* SAPlayerPresenter.swift */,
|
||||
A4681FBE22010ECF0018AB51 /* LockScreenViewProtocol.swift */,
|
||||
A4681F852200DA8B0018AB51 /* AudioClockDirector.swift */,
|
||||
A49B78C3221A78DE00BBA862 /* DownloadProgressDirector.swift */,
|
||||
A4681F932200E2020018AB51 /* Engine */,
|
||||
A4681F9B2200E4850018AB51 /* Model */,
|
||||
A4681F842200D91D0018AB51 /* Util */,
|
||||
);
|
||||
path = Source;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BC3CA7F9E30CC8F7E2DD044DD34432FC /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -318,6 +483,14 @@
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0930;
|
||||
LastUpgradeCheck = 0930;
|
||||
TargetAttributes = {
|
||||
042ACE071BA515F4DE0E0C8007C3F0EE = {
|
||||
LastSwiftMigration = 1010;
|
||||
};
|
||||
E50DAD13FFD3FC8036073A58BF8423D4 = {
|
||||
LastSwiftMigration = 1010;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 2D8E8EC45A3A1A1D94AE762CB5028504 /* Build configuration list for PBXProject "Pods" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
@@ -343,8 +516,41 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1606D9AEB876D4A642C3CE2985C600C9 /* ReplaceMe.swift in Sources */,
|
||||
A41AA0D2238BB9B600A467E1 /* SAPlayingStatus.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 */,
|
||||
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 */,
|
||||
A4681FD6220113BF0018AB51 /* AudioThrottler.swift in Sources */,
|
||||
A4681FCC2201139B0018AB51 /* AudioDiskEngine.swift in Sources */,
|
||||
A4681FDE220113DE0018AB51 /* Date.swift in Sources */,
|
||||
A4681FDA220113D00018AB51 /* StreamProgressDTO.swift in Sources */,
|
||||
A4681FDB220113D40018AB51 /* FileStorage.swift in Sources */,
|
||||
A4681FD4220113BA0018AB51 /* AudioParserPacketListener.swift in Sources */,
|
||||
E08AD6157EF688FE832F866CBCDA3532 /* SwiftAudioPlayer-dummy.m in Sources */,
|
||||
A4681FDD220113DC0018AB51 /* URL.swift in Sources */,
|
||||
A4681FC82201138E0018AB51 /* SAPlayerPresenter.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 */,
|
||||
A4FBA6B9221BAF8700D5A353 /* SAAudioAvailabilityRange.swift in Sources */,
|
||||
A4681FCD2201139E0018AB51 /* AudioStreamEngine.swift in Sources */,
|
||||
A4681FD9220113CD0018AB51 /* AudioStreamWorker.swift in Sources */,
|
||||
A4681FDF220113E20018AB51 /* DirectorThreadSafeClosures.swift in Sources */,
|
||||
A4681FCB220113980018AB51 /* AudioEngine.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -461,7 +667,7 @@
|
||||
GCC_PREFIX_HEADER = "Target Support Files/SwiftAudioPlayer/SwiftAudioPlayer-prefix.pch";
|
||||
INFOPLIST_FILE = "Target Support Files/SwiftAudioPlayer/Info.plist";
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
MODULEMAP_FILE = "Target Support Files/SwiftAudioPlayer/SwiftAudioPlayer.modulemap";
|
||||
PRODUCT_MODULE_NAME = SwiftAudioPlayer;
|
||||
@@ -470,7 +676,7 @@
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 4.0;
|
||||
SWIFT_VERSION = 4.2;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
VERSION_INFO_PREFIX = "";
|
||||
@@ -493,7 +699,7 @@
|
||||
GCC_PREFIX_HEADER = "Target Support Files/SwiftAudioPlayer/SwiftAudioPlayer-prefix.pch";
|
||||
INFOPLIST_FILE = "Target Support Files/SwiftAudioPlayer/Info.plist";
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
MODULEMAP_FILE = "Target Support Files/SwiftAudioPlayer/SwiftAudioPlayer.modulemap";
|
||||
PRODUCT_MODULE_NAME = SwiftAudioPlayer;
|
||||
@@ -502,7 +708,7 @@
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
SWIFT_VERSION = 4.0;
|
||||
SWIFT_VERSION = 4.2;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -515,6 +721,7 @@
|
||||
baseConfigurationReference = E1C110BCB4A9F826B59DC6905BAB3C6E /* Pods-SwiftAudioPlayer_Example.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
|
||||
@@ -526,7 +733,7 @@
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
INFOPLIST_FILE = "Target Support Files/Pods-SwiftAudioPlayer_Example/Info.plist";
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.3;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
MACH_O_TYPE = staticlib;
|
||||
MODULEMAP_FILE = "Target Support Files/Pods-SwiftAudioPlayer_Example/Pods-SwiftAudioPlayer_Example.modulemap";
|
||||
@@ -539,6 +746,7 @@
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 4.2;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
VERSION_INFO_PREFIX = "";
|
||||
@@ -608,6 +816,7 @@
|
||||
baseConfigurationReference = 69AF5444212FEC2674325627F26305AD /* Pods-SwiftAudioPlayer_Example.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
|
||||
@@ -619,7 +828,7 @@
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
INFOPLIST_FILE = "Target Support Files/Pods-SwiftAudioPlayer_Example/Info.plist";
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.3;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
MACH_O_TYPE = staticlib;
|
||||
MODULEMAP_FILE = "Target Support Files/Pods-SwiftAudioPlayer_Example/Pods-SwiftAudioPlayer_Example.modulemap";
|
||||
@@ -631,6 +840,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
SWIFT_VERSION = 4.2;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -212,11 +212,13 @@
|
||||
TargetAttributes = {
|
||||
607FACCF1AFB9204008FA782 = {
|
||||
CreatedOnToolsVersion = 6.3.1;
|
||||
LastSwiftMigration = 0900;
|
||||
DevelopmentTeam = R2392A68YQ;
|
||||
LastSwiftMigration = 1010;
|
||||
};
|
||||
607FACE41AFB9204008FA782 = {
|
||||
CreatedOnToolsVersion = 6.3.1;
|
||||
LastSwiftMigration = 0900;
|
||||
DevelopmentTeam = R2392A68YQ;
|
||||
LastSwiftMigration = 1010;
|
||||
TestTargetID = 607FACCF1AFB9204008FA782;
|
||||
};
|
||||
};
|
||||
@@ -410,7 +412,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.3;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -456,7 +458,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.3;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
@@ -469,13 +471,13 @@
|
||||
baseConfigurationReference = 65A66AB4C3016E8BB53FF3E0 /* Pods-SwiftAudioPlayer_Example.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_SWIFT3_OBJC_INFERENCE = Default;
|
||||
SWIFT_VERSION = 4.0;
|
||||
SWIFT_VERSION = 4.2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -484,13 +486,13 @@
|
||||
baseConfigurationReference = 4B5DD2AE0B23A759D18926DC /* Pods-SwiftAudioPlayer_Example.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_SWIFT3_OBJC_INFERENCE = Default;
|
||||
SWIFT_VERSION = 4.0;
|
||||
SWIFT_VERSION = 4.2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -498,6 +500,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = BBD877782CC67FBCC7BF7532 /* Pods-SwiftAudioPlayer_Tests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/Developer/Library/Frameworks",
|
||||
"$(inherited)",
|
||||
@@ -510,8 +513,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_SWIFT3_OBJC_INFERENCE = Default;
|
||||
SWIFT_VERSION = 4.0;
|
||||
SWIFT_VERSION = 4.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftAudioPlayer_Example.app/SwiftAudioPlayer_Example";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -520,6 +522,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 0B7D1E6C00E83B4AF8AA1781 /* Pods-SwiftAudioPlayer_Tests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/Developer/Library/Frameworks",
|
||||
"$(inherited)",
|
||||
@@ -528,8 +531,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_SWIFT3_OBJC_INFERENCE = Default;
|
||||
SWIFT_VERSION = 4.0;
|
||||
SWIFT_VERSION = 4.2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftAudioPlayer_Example.app/SwiftAudioPlayer_Example";
|
||||
};
|
||||
name = Release;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftAudioPlayer
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
@@ -14,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
@@ -40,6 +41,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||
SAPlayer.Downloader.setBackgroundCompletionHandler(completionHandler)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13771" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="vXZ-lx-hvc">
|
||||
<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>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13772"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@@ -20,11 +20,158 @@
|
||||
<view key="view" contentMode="scaleToFill" id="kh9-bI-dsS">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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>
|
||||
<action selector="scrubberSeeked:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="hTi-fq-lrl"/>
|
||||
<action selector="scrubberSeeked:" destination="vXZ-lx-hvc" eventType="touchUpOutside" id="mFP-SW-38w"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<connections>
|
||||
<action selector="rateChanged:" destination="vXZ-lx-hvc" eventType="valueChanged" id="FDJ-jA-bm8"/>
|
||||
</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"/>
|
||||
<segments>
|
||||
<segment title="Soundbite"/>
|
||||
<segment title="Acquired"/>
|
||||
<segment title="Y Combinator"/>
|
||||
</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"/>
|
||||
<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.0" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yUQ-mI-ozK">
|
||||
<rect key="frame" x="157" y="435" width="61" 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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="lTK-Hd-Tl2" firstAttribute="leading" secondItem="kh9-bI-dsS" secondAttribute="leading" constant="16" id="1wb-IW-jYz"/>
|
||||
<constraint firstItem="j3w-gr-HzF" firstAttribute="leading" secondItem="lTK-Hd-Tl2" secondAttribute="leading" id="26c-ZJ-768"/>
|
||||
<constraint firstItem="jUc-tP-CC5" firstAttribute="top" secondItem="KDu-ea-kF8" secondAttribute="bottom" constant="80" id="5sT-An-9vw"/>
|
||||
<constraint firstItem="6d9-Bc-hIz" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="KDu-ea-kF8" secondAttribute="trailing" constant="8" symbolic="YES" id="60t-zV-EiY"/>
|
||||
<constraint firstItem="joK-xi-MCo" firstAttribute="leading" secondItem="lTK-Hd-Tl2" secondAttribute="leading" id="7KA-Mg-HFD"/>
|
||||
<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="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="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="yUQ-mI-ozK" firstAttribute="centerX" secondItem="kh9-bI-dsS" secondAttribute="centerX" id="a66-h4-WVf"/>
|
||||
<constraint firstItem="Urj-Dv-41y" firstAttribute="trailing" secondItem="lTK-Hd-Tl2" secondAttribute="trailing" id="aKt-EV-Bwd"/>
|
||||
<constraint firstItem="tFH-sY-Xu9" firstAttribute="top" secondItem="1IX-z5-wWx" secondAttribute="bottom" constant="27" id="bIq-V0-Sac"/>
|
||||
<constraint firstItem="tFH-sY-Xu9" firstAttribute="leading" secondItem="kh9-bI-dsS" secondAttribute="leading" constant="62.5" id="cH6-q6-Lel"/>
|
||||
<constraint firstItem="jUc-tP-CC5" firstAttribute="centerX" secondItem="kh9-bI-dsS" secondAttribute="centerX" id="cgM-Nj-yit"/>
|
||||
<constraint firstItem="KDu-ea-kF8" firstAttribute="top" secondItem="joK-xi-MCo" secondAttribute="bottom" constant="32" id="dLw-rF-Pfb"/>
|
||||
<constraint firstItem="w2a-RA-zmI" firstAttribute="leading" secondItem="lTK-Hd-Tl2" secondAttribute="leading" id="daz-b0-eCC"/>
|
||||
<constraint firstItem="jUc-tP-CC5" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="tFH-sY-Xu9" secondAttribute="trailing" constant="8" symbolic="YES" id="fS9-Ce-4ph"/>
|
||||
<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="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 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 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"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="audioSelector" destination="joK-xi-MCo" id="GmY-Xg-be0"/>
|
||||
<outlet property="bufferProgress" destination="lTK-Hd-Tl2" id="54k-by-qb2"/>
|
||||
<outlet property="currentTimestampLabel" destination="j3w-gr-HzF" id="5Lh-aS-pat"/>
|
||||
<outlet property="currentUrlLocationLabel" destination="1IX-z5-wWx" id="MuO-fF-ZxL"/>
|
||||
<outlet property="downloadButton" destination="KDu-ea-kF8" id="5o4-1h-y06"/>
|
||||
<outlet property="durationLabel" destination="Urj-Dv-41y" id="mIq-eh-int"/>
|
||||
<outlet property="playPauseButton" destination="jUc-tP-CC5" id="e9C-zV-A1B"/>
|
||||
<outlet property="rateLabel" destination="yUQ-mI-ozK" id="Dx4-lO-A1B"/>
|
||||
<outlet property="rateSlider" destination="vfk-OJ-S3T" id="mNc-ET-aNM"/>
|
||||
<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="streamButton" destination="6d9-Bc-hIz" id="DZe-ga-3RV"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="x5A-6p-PRh" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="132" y="103.89805097451276"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -7,18 +7,259 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftAudioPlayer
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet weak var currentUrlLocationLabel: UILabel!
|
||||
@IBOutlet weak var bufferProgress: UIProgressView!
|
||||
@IBOutlet weak var scrubberSlider: UISlider!
|
||||
|
||||
@IBOutlet weak var playPauseButton: UIButton!
|
||||
@IBOutlet weak var skipBackwardButton: UIButton!
|
||||
@IBOutlet weak var skipForwardButton: UIButton!
|
||||
|
||||
@IBOutlet weak var audioSelector: UISegmentedControl!
|
||||
@IBOutlet weak var streamButton: UIButton!
|
||||
@IBOutlet weak var downloadButton: UIButton!
|
||||
@IBOutlet weak var rateSlider: UISlider!
|
||||
|
||||
@IBOutlet weak var rateLabel: UILabel!
|
||||
|
||||
@IBOutlet weak var durationLabel: UILabel!
|
||||
@IBOutlet weak var currentTimestampLabel: UILabel!
|
||||
|
||||
var isDownloading: Bool = false
|
||||
var isStreaming: Bool = false
|
||||
var beingSeeked: Bool = false
|
||||
|
||||
var duration: Double = 0.0
|
||||
|
||||
var isPlayable: Bool = false {
|
||||
didSet {
|
||||
if isPlayable {
|
||||
playPauseButton.isEnabled = true
|
||||
skipBackwardButton.isEnabled = true
|
||||
skipForwardButton.isEnabled = true
|
||||
} else {
|
||||
playPauseButton.isEnabled = false
|
||||
skipBackwardButton.isEnabled = false
|
||||
skipForwardButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// Do any additional setup after loading the view, typically from a nib.
|
||||
|
||||
adjustSpeed()
|
||||
|
||||
isPlayable = false
|
||||
selectedAudio = AudioInfo(index: 0)
|
||||
|
||||
_ = 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 }
|
||||
self.durationLabel.text = SAPlayer.prettifyTimestamp(duration)
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
_ = 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 }
|
||||
|
||||
self.currentTimestampLabel.text = SAPlayer.prettifyTimestamp(position)
|
||||
|
||||
guard self.duration != 0 else { return }
|
||||
|
||||
self.scrubberSlider.value = Float(position/self.duration)
|
||||
}
|
||||
|
||||
_ = SAPlayer.Updates.AudioDownloading.subscribe { [weak self] (url, progress) in
|
||||
guard let self = self else { return }
|
||||
guard url == self.selectedAudio.url else { return }
|
||||
|
||||
if self.isDownloading {
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
self.downloadButton.setTitle("Cancel \(String(format: "%.2f", (progress * 100)))%", for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = 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 }
|
||||
|
||||
if self.duration == 0.0 { return }
|
||||
|
||||
let progress = Float((buffer.totalDurationBuffered + buffer.startingBufferTimePositon) / self.duration)
|
||||
|
||||
self.bufferProgress.progress = progress
|
||||
|
||||
if progress >= 0.99 {
|
||||
self.streamButton.isEnabled = false
|
||||
}
|
||||
|
||||
self.isPlayable = buffer.isReadyForPlaying
|
||||
}
|
||||
|
||||
_ = 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 }
|
||||
|
||||
switch playing {
|
||||
case .playing:
|
||||
self.isPlayable = true
|
||||
self.playPauseButton.setTitle("Pause", for: .normal)
|
||||
return
|
||||
case .paused:
|
||||
self.isPlayable = true
|
||||
self.playPauseButton.setTitle("Play", for: .normal)
|
||||
return
|
||||
case .buffering:
|
||||
self.isPlayable = false
|
||||
self.playPauseButton.setTitle("Loading", for: .normal)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
// Dispose of any resources that can be recreated.
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
@IBAction func scrubberSeeked(_ sender: Any) {
|
||||
let value = Double(scrubberSlider.value) * duration
|
||||
SAPlayer.shared.seekTo(seconds: value)
|
||||
beingSeeked = false
|
||||
}
|
||||
|
||||
|
||||
@IBAction func rateChanged(_ sender: Any) {
|
||||
adjustSpeed()
|
||||
}
|
||||
|
||||
@IBAction func downloadTouched(_ sender: Any) {
|
||||
if !isDownloading {
|
||||
if let savedUrl = SAPlayer.Downloader.getSavedUrl(forRemoteUrl: selectedAudio.url) {
|
||||
SAPlayer.Downloader.deleteDownloaded(withSavedUrl: savedUrl)
|
||||
downloadButton.setTitle("Download", for: .normal)
|
||||
streamButton.isEnabled = true
|
||||
isDownloading = false
|
||||
} else {
|
||||
downloadButton.setTitle("Cancel 0%", for: .normal)
|
||||
isDownloading = true
|
||||
SAPlayer.Downloader.downloadAudio(withRemoteUrl: selectedAudio.url, completion: { [weak self] url in
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.currentUrlLocationLabel.text = "saved to: \(url.lastPathComponent)"
|
||||
self.savedUrls[self.selectedAudio] = url
|
||||
|
||||
SAPlayer.shared.initializeSavedAudio(withSavedUrl: url)
|
||||
}
|
||||
})
|
||||
streamButton.isEnabled = false
|
||||
}
|
||||
} else {
|
||||
SAPlayer.Downloader.cancelDownload(withRemoteUrl: selectedAudio.url)
|
||||
downloadButton.setTitle("Download", for: .normal)
|
||||
streamButton.isEnabled = true
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func streamTouched(_ sender: Any) {
|
||||
if !isStreaming {
|
||||
SAPlayer.shared.initializeAudio(withRemoteUrl: selectedAudio.url)
|
||||
streamButton.setTitle("Cancel streaming", for: .normal)
|
||||
downloadButton.isEnabled = false
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func playPauseTouched(_ sender: Any) {
|
||||
SAPlayer.shared.togglePlayAndPause()
|
||||
}
|
||||
|
||||
@IBAction func skipBackwardTouched(_ sender: Any) {
|
||||
SAPlayer.shared.skipBackwards()
|
||||
}
|
||||
|
||||
@IBAction func skipForwardTouched(_ sender: Any) {
|
||||
SAPlayer.shared.skipForward()
|
||||
}
|
||||
|
||||
private func adjustSpeed() {
|
||||
let speed = rateSlider.value
|
||||
rateLabel.text = "rate: \(speed)x"
|
||||
SAPlayer.shared.rate = Double(speed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2019 tanhakabir <tanhakabir.ca@gmail.com>
|
||||
Copyright (c) 2019 Tanha Kabir <tanhakabir.ca@gmail.com>, Jon Mercer <mercer.jon@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
# SwiftAudioPlayer
|
||||
|
||||
[](https://travis-ci.org/tanhakabir/SwiftAudioPlayer)
|
||||
[](https://cocoapods.org/pods/SwiftAudioPlayer)
|
||||
[](https://cocoapods.org/pods/SwiftAudioPlayer)
|
||||
[](https://cocoapods.org/pods/SwiftAudioPlayer)
|
||||
|
||||
## Example
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
### Requirements
|
||||
|
||||
SwiftAudioPlayer is only available for iOS 10.0 and higher.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Example Project
|
||||
|
||||
To run the example project, clone the repo, and run `pod install` from the Example directory first.
|
||||
|
||||
## Requirements
|
||||
|
||||
## Installation
|
||||
### Installation
|
||||
|
||||
SwiftAudioPlayer is available through [CocoaPods](https://cocoapods.org). To install
|
||||
it, simply add the following line to your Podfile:
|
||||
@@ -20,10 +29,167 @@ it, simply add the following line to your Podfile:
|
||||
pod 'SwiftAudioPlayer'
|
||||
```
|
||||
|
||||
## Author
|
||||
### Usage
|
||||
|
||||
tanhakabir, tanhakabir.ca@gmail.com
|
||||
To play remote audio:
|
||||
```swift
|
||||
let url = URL(string: "https://randomwebsite.com/audio.mp3")!
|
||||
SAPlayer.shared.initializeAudio(withRemoteUrl: url)
|
||||
SAPlayer.shared.play()
|
||||
```
|
||||
|
||||
## License
|
||||
To set the display information for the lockscreen:
|
||||
```swift
|
||||
let info = SALockScreenInfo(title: "Random audio", artist: "Foo", artwork: UIImage(), releaseDate: 123456789)
|
||||
SAPlayer.shared.mediaInfo = info
|
||||
```
|
||||
|
||||
To receive streaming progress:
|
||||
```swift
|
||||
@IBOutlet weak var bufferProgress: UIProgressView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
_ = SAPlayer.Updates.StreamingBuffer.subscribe{ [weak self] (url, buffer) in
|
||||
guard let self = self else { return }
|
||||
guard url == self.selectedAudioUrl else { return }
|
||||
|
||||
let progress = Float((buffer.totalDurationBuffered + buffer.startingBufferTimePositon) / self.duration)
|
||||
|
||||
self.bufferProgress.progress = progress
|
||||
|
||||
self.isPlayable = buffer.isReadyForPlaying
|
||||
}
|
||||
}
|
||||
```
|
||||
Look at the [Updates](#SAPlayer.Updates) section to see usage details and other updates to follow.
|
||||
|
||||
**Important:** For app in background downloading please refer to [note](#important-step-for-background-downloads).
|
||||
|
||||
For more details and specifics look at the [API documentation](#api-in-detail) below.
|
||||
|
||||
## Contact
|
||||
|
||||
### Issues
|
||||
|
||||
Submit any issues or requests [on the Github repo](https://github.com/tanhakabir/SwiftAudioPlayer/issues).
|
||||
|
||||
### Any questions?
|
||||
|
||||
Feel free to reach out to either of us:
|
||||
|
||||
[tanhakabir](https://github.com/tanhakabir), tanhakabir.ca@gmail.com
|
||||
[JonMercer](https://github.com/JonMercer), mercer.jon@gmail.com
|
||||
|
||||
### License
|
||||
|
||||
SwiftAudioPlayer is available under the MIT license. See the LICENSE file for more info.
|
||||
|
||||
---
|
||||
|
||||
# API in detail
|
||||
|
||||
## SAPlayer.Downloader
|
||||
|
||||
Use functionaity from Downloader to save audio files from remote locations for future offline playback.
|
||||
|
||||
Audio files are saved under custom naming scheme on device and are recoverable with original remote URL for file.
|
||||
|
||||
#### Important step for background downloads
|
||||
|
||||
To ensure that your app will keep downloading audio in the background be sure to add the following to `AppDelegate.swift`:
|
||||
|
||||
```swift
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||
SAPlayer.Downloader.setBackgroundCompletionHandler(completionHandler)
|
||||
}
|
||||
```
|
||||
|
||||
### Downloading
|
||||
|
||||
Downloads will be held on pause when active stream is started, and will resume downloads when streaming is done.
|
||||
|
||||
Use the following to start downloading audio in the background:
|
||||
|
||||
```swift
|
||||
func downloadAudio(withRemoteUrl url: URL, completion: @escaping (_ savedUrl: URL) -> ())
|
||||
```
|
||||
|
||||
It will call the completion handler you pass after successful download with the location of the downloaded file on the device.
|
||||
|
||||
And use the following to stop any active or prevent future downloads of the corresponding remote URL:
|
||||
|
||||
```swift
|
||||
func cancelDownload(withRemoteUrl url: URL)
|
||||
```
|
||||
|
||||
### Manage Downloaded
|
||||
|
||||
Use the following to manage downloaded audio files.
|
||||
|
||||
Checks if downloaded already:
|
||||
```swift
|
||||
func isDownloaded(withRemoteUrl url: URL) -> Bool
|
||||
```
|
||||
|
||||
Get URL of audio file saved on device corresponding to remote location:
|
||||
```swift
|
||||
func getSavedUrl(forRemoteUrl url: URL) -> URL?
|
||||
```
|
||||
|
||||
Delete downloaded audio if it exists:
|
||||
```swift
|
||||
func deleteDownloaded(withSavedUrl url: URL)
|
||||
```
|
||||
|
||||
## SAPlayer.Updates
|
||||
|
||||
Receive updates for changing values from the player, such as the duration, elapsed time of playing audio, download progress, and etc.
|
||||
|
||||
All subscription functions for updates take the form of:
|
||||
```swift
|
||||
func subscribe(_ closure: @escaping (_ url: URL, _ payload: <Payload>) -> ()) -> UInt
|
||||
```
|
||||
|
||||
- `closure`: The closure that will receive the updates. It's recommended to have a weak reference to a class that uses these functions.
|
||||
- `url`: The corresponding remote URL for the update. In the case there might be multiple files observed, such as downloading many files at once or switching over from playing one audio to another and the updates corresponding to the previous aren't silenced on switch-over.
|
||||
- `payload`: The updated value.
|
||||
- Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure.
|
||||
|
||||
Similarily unsubscribe takes the form of:
|
||||
```swift
|
||||
func unsubscribe(_ id: UInt)
|
||||
```
|
||||
|
||||
- `id`: The closure with this id will stop receiving updates.
|
||||
|
||||
|
||||
### ElapsedTime
|
||||
Payload = `Double`
|
||||
|
||||
Changes in the timestamp/elapsed time of the current initialized audio. Aka, where the scrubber's pointer of the audio should be at.
|
||||
|
||||
Subscribe to this to update views on changes in position of which part of audio is being played.
|
||||
|
||||
### 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.
|
||||
|
||||
### PlayingStatus
|
||||
Payload = `SAPlayingStatus`
|
||||
|
||||
Changes in the playing status of the player. Can be one of the following 3: `playing`, `paused`, `buffering`.
|
||||
|
||||
### StreamingBuffer
|
||||
Payload = `SAAudioAvailabilityRange`
|
||||
|
||||
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.
|
||||
|
||||
### AudioDownloading
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// AudioClockDirector.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import CoreMedia
|
||||
|
||||
class AudioClockDirector {
|
||||
static let shared = AudioClockDirector()
|
||||
|
||||
private var needleClosures: DirectorThreadSafeClosures<Needle> = DirectorThreadSafeClosures()
|
||||
private var durationClosures: DirectorThreadSafeClosures<Duration> = DirectorThreadSafeClosures()
|
||||
private var playingStatusClosures: DirectorThreadSafeClosures<SAPlayingStatus> = DirectorThreadSafeClosures()
|
||||
private var bufferClosures: DirectorThreadSafeClosures<SAAudioAvailabilityRange> = DirectorThreadSafeClosures()
|
||||
|
||||
private init() {}
|
||||
|
||||
func create() {}
|
||||
|
||||
func clear() {
|
||||
needleClosures.clear()
|
||||
durationClosures.clear()
|
||||
playingStatusClosures.clear()
|
||||
bufferClosures.clear()
|
||||
}
|
||||
|
||||
// MARK: - Attaches
|
||||
|
||||
// Needle
|
||||
func attachToChangesInNeedle(closure: @escaping (Key, Needle) throws -> Void) -> UInt {
|
||||
return needleClosures.attach(closure: closure)
|
||||
}
|
||||
|
||||
|
||||
// Duration
|
||||
func attachToChangesInDuration(closure: @escaping (Key, Duration) throws -> Void) -> UInt {
|
||||
return durationClosures.attach(closure: closure)
|
||||
}
|
||||
|
||||
|
||||
// Playing status
|
||||
func attachToChangesInPlayingStatus(closure: @escaping (Key, SAPlayingStatus) throws -> Void) -> UInt{
|
||||
return playingStatusClosures.attach(closure: closure)
|
||||
}
|
||||
|
||||
|
||||
// Buffer
|
||||
func attachToChangesInBufferedRange(closure: @escaping (Key, SAAudioAvailabilityRange) throws -> Void) -> UInt{
|
||||
return bufferClosures.attach(closure: closure)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Detaches
|
||||
func detachFromChangesInNeedle(withID id: UInt) {
|
||||
needleClosures.detach(id: id)
|
||||
}
|
||||
|
||||
func detachFromChangesInDuration(withID id: UInt) {
|
||||
durationClosures.detach(id: id)
|
||||
}
|
||||
|
||||
func detachFromChangesInPlayingStatus(withID id: UInt) {
|
||||
playingStatusClosures.detach(id: id)
|
||||
}
|
||||
|
||||
func detachFromChangesInBufferedRange(withID id: UInt) {
|
||||
bufferClosures.detach(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receives notifications from AudioEngine on ticks
|
||||
extension AudioClockDirector {
|
||||
func needleTick(_ key: Key, needle: Needle) {
|
||||
needleClosures.broadcast(key: key, payload: needle)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioClockDirector {
|
||||
func durationWasChanged(_ key: Key, duration: Duration) {
|
||||
durationClosures.broadcast(key: key, payload: duration)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioClockDirector {
|
||||
func audioPlayingStatusWasChanged(_ key: Key, status: SAPlayingStatus) {
|
||||
playingStatusClosures.broadcast(key: key, payload: status)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioClockDirector {
|
||||
func changeInAudioBuffered(_ key: Key, buffered: SAAudioAvailabilityRange) {
|
||||
bufferClosures.broadcast(key: key, payload: buffered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// DownloadProgressDirector.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-02-17.
|
||||
// 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
|
||||
|
||||
class DownloadProgressDirector {
|
||||
static let shared = DownloadProgressDirector()
|
||||
|
||||
var closures: DirectorThreadSafeClosures<Double> = DirectorThreadSafeClosures()
|
||||
|
||||
private init() {
|
||||
AudioDataManager.shared.attach { [weak self] (key, progress) in
|
||||
self?.closures.broadcast(key: key, payload: progress)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// AudioDiskEngine.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import AVFoundation
|
||||
|
||||
class AudioDiskEngine: AudioEngine {
|
||||
var audioFormat: AVAudioFormat?
|
||||
var audioSampleRate: Float = 0
|
||||
var audioLengthSamples: AVAudioFramePosition = 0
|
||||
var seekFrame: AVAudioFramePosition = 0
|
||||
var currentPosition: AVAudioFramePosition = 0
|
||||
|
||||
var audioFile: AVAudioFile?
|
||||
|
||||
var currentFrame: AVAudioFramePosition {
|
||||
guard let lastRenderTime = playerNode.lastRenderTime,
|
||||
let playerTime = playerNode.playerTime(forNodeTime: lastRenderTime) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return playerTime.sampleTime
|
||||
}
|
||||
|
||||
var audioLengthSeconds: Float = 0
|
||||
|
||||
init(withSavedUrl url: AudioURL, delegate:AudioEngineDelegate?) {
|
||||
Log.info(url.key)
|
||||
|
||||
do {
|
||||
audioFile = try AVAudioFile(forReading: url)
|
||||
} catch {
|
||||
Log.monitor(error.localizedDescription)
|
||||
}
|
||||
|
||||
super.init(url: url, delegate: delegate, engineAudioFormat: audioFile?.processingFormat ?? AudioEngine.defaultEngineAudioFormat)
|
||||
|
||||
if let file = audioFile {
|
||||
Log.debug("Audio file exists")
|
||||
audioLengthSamples = file.length
|
||||
audioFormat = file.processingFormat
|
||||
audioSampleRate = Float(audioFormat?.sampleRate ?? 44100)
|
||||
audioLengthSeconds = Float(audioLengthSamples) / audioSampleRate
|
||||
duration = Duration(audioLengthSeconds)
|
||||
bufferedSeconds = SAAudioAvailabilityRange(startingNeedle: 0, durationLoadedByNetwork: 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()
|
||||
}
|
||||
|
||||
scheduleAudioFile()
|
||||
}
|
||||
|
||||
private func scheduleAudioFile() {
|
||||
guard let audioFile = audioFile else { return }
|
||||
|
||||
playerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)
|
||||
}
|
||||
|
||||
private func updateNeedle() {
|
||||
guard engine.isRunning else { return }
|
||||
|
||||
currentPosition = currentFrame + seekFrame
|
||||
currentPosition = max(currentPosition, 0)
|
||||
currentPosition = min(currentPosition, audioLengthSamples)
|
||||
|
||||
if currentPosition >= audioLengthSamples {
|
||||
playerNode.stop()
|
||||
if state == .resumed {
|
||||
state = .suspended
|
||||
}
|
||||
delegate?.didEndPlaying()
|
||||
}
|
||||
|
||||
guard audioSampleRate != 0 else {
|
||||
Log.error("Missing audio sample rate in update needle timer function!")
|
||||
return
|
||||
}
|
||||
|
||||
needle = Double(Float(currentPosition)/audioSampleRate)
|
||||
}
|
||||
|
||||
override func seek(toNeedle needle: Needle) {
|
||||
guard let audioFile = audioFile else {
|
||||
Log.error("did not have audio file when trying to seek")
|
||||
return
|
||||
}
|
||||
|
||||
let playing = playerNode.isPlaying
|
||||
|
||||
self.needle = needle // to tick while paused
|
||||
|
||||
seekFrame = AVAudioFramePosition(Float(needle) * audioSampleRate)
|
||||
seekFrame = max(seekFrame, 0)
|
||||
seekFrame = min(seekFrame, audioLengthSamples)
|
||||
currentPosition = seekFrame
|
||||
|
||||
playerNode.stop()
|
||||
|
||||
if currentPosition < audioLengthSamples {
|
||||
playerNode.scheduleSegment(audioFile, startingFrame: seekFrame, frameCount: AVAudioFrameCount(audioLengthSamples - seekFrame), at: nil, completionHandler: nil)
|
||||
|
||||
if playing {
|
||||
playerNode.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func invalidate() {
|
||||
//Nothing to invalidate for disk
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// AudioEngine.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import AVFoundation
|
||||
|
||||
protocol AudioEngineProtocol {
|
||||
func play()
|
||||
func pause()
|
||||
func seek(toNeedle needle: Needle)
|
||||
func setSpeed(speed: Double)
|
||||
func invalidate()
|
||||
}
|
||||
|
||||
protocol AudioEngineDelegate: AnyObject {
|
||||
func didEndPlaying() //for auto play
|
||||
func didError()
|
||||
}
|
||||
|
||||
class AudioEngine: AudioEngineProtocol {
|
||||
weak var delegate:AudioEngineDelegate?
|
||||
let key:Key
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
let playerNode = AVAudioPlayerNode()
|
||||
let rateNode: AVAudioUnitTimePitch
|
||||
|
||||
var timer: Timer?
|
||||
|
||||
static let defaultEngineAudioFormat: AVAudioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false)!
|
||||
|
||||
var state:TimerState = .suspended
|
||||
enum TimerState {
|
||||
case suspended
|
||||
case resumed
|
||||
}
|
||||
|
||||
var audioSpeed: Double = 1.0 {
|
||||
didSet {
|
||||
rateNode.rate = Float(audioSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
var needle: Needle = -1 {
|
||||
didSet {
|
||||
if needle >= 0 && oldValue != needle {
|
||||
AudioClockDirector.shared.needleTick(key, needle: needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var duration: Duration = -1 {
|
||||
didSet {
|
||||
if duration >= 0 && oldValue != duration {
|
||||
AudioClockDirector.shared.durationWasChanged(key, duration: duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var playingStatus: SAPlayingStatus? = nil {
|
||||
didSet {
|
||||
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) {
|
||||
didSet {
|
||||
if bufferedSeconds.startingNeedle == 0.0 && bufferedSeconds.durationLoadedByNetwork == 0.0 {
|
||||
bufferedSecondsDebouncer = bufferedSeconds
|
||||
AudioClockDirector.shared.changeInAudioBuffered(key, buffered: bufferedSeconds)
|
||||
return
|
||||
}
|
||||
|
||||
if bufferedSeconds.startingNeedle == oldValue.startingNeedle && bufferedSeconds.durationLoadedByNetwork == oldValue.durationLoadedByNetwork {
|
||||
return
|
||||
}
|
||||
|
||||
if bufferedSeconds.durationLoadedByNetwork - DEBOUNCING_BUFFER_TIME < bufferedSecondsDebouncer.durationLoadedByNetwork {
|
||||
Log.debug("skipping pushing buffer: \(bufferedSeconds)")
|
||||
return
|
||||
}
|
||||
|
||||
bufferedSecondsDebouncer = bufferedSeconds
|
||||
AudioClockDirector.shared.changeInAudioBuffered(key, buffered: bufferedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
init(url: AudioURL, delegate:AudioEngineDelegate?, engineAudioFormat: AVAudioFormat) {
|
||||
self.key = url.key
|
||||
self.delegate = delegate
|
||||
// https://forums.developer.apple.com/thread/5874
|
||||
// https://forums.developer.apple.com/thread/6050
|
||||
// AVAudioTimePitchAlgorithm.timeDomain (just in case we want it)
|
||||
var componentDescription: AudioComponentDescription {
|
||||
get {
|
||||
var ret = AudioComponentDescription()
|
||||
ret.componentType = kAudioUnitType_FormatConverter
|
||||
ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
rateNode = AVAudioUnitTimePitch(audioComponentDescription: componentDescription)
|
||||
|
||||
engine.attach(playerNode)
|
||||
engine.attach(rateNode)
|
||||
engine.connect(playerNode, to: rateNode, format: engineAudioFormat)
|
||||
engine.connect(rateNode, to: engine.mainMixerNode, format: engineAudioFormat)
|
||||
|
||||
engine.prepare()
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
if state == .resumed {
|
||||
engine.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func updateIsPlaying() {
|
||||
if !bufferedSeconds.isPlayable {
|
||||
playingStatus = .buffering
|
||||
return
|
||||
}
|
||||
|
||||
let isPlaying = engine.isRunning && playerNode.isPlaying
|
||||
playingStatus = isPlaying ? .playing : .paused
|
||||
}
|
||||
|
||||
func play() {
|
||||
// https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button
|
||||
if !engine.isRunning {
|
||||
do {
|
||||
try engine.start()
|
||||
|
||||
} catch let error {
|
||||
Log.monitor(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
playerNode.play()
|
||||
|
||||
if state == .suspended {
|
||||
state = .resumed
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
// https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button
|
||||
playerNode.pause()
|
||||
engine.pause()
|
||||
|
||||
if state == .resumed {
|
||||
state = .suspended
|
||||
}
|
||||
}
|
||||
|
||||
func seek(toNeedle needle: Needle) {
|
||||
fatalError("No implementation for seek inAudioEngine, should be using streaming or disk type")
|
||||
}
|
||||
|
||||
func setSpeed(speed: Double) {
|
||||
audioSpeed = speed
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//
|
||||
// AudioStreamEngine.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
|
||||
/**
|
||||
Start of the streaming chain. Get PCM buffer from lower chain and feed it to
|
||||
engine
|
||||
|
||||
Main responsibilities:
|
||||
POLL FOR BUFFER. When we start a stream it takes time for the lower chain to
|
||||
receive audio format. We don't know how long this would take. Therefore we poll
|
||||
continually. We also poll continually when user seeks because they could have
|
||||
seeked beyond pcm buffer, and down-chain buffer. We keep polling until we fill
|
||||
N buffers. If we stick to one buffer the audio sounds choppy because sometimes
|
||||
the parser takes longer than usual to parse a buffer
|
||||
|
||||
RECURSE FOR BUFFER. When we receive N buffers we switch to recursive mode. This
|
||||
means we only ask for the next buffer when one of the loaded buffers are
|
||||
used up. This is to prevent high CPU usage (100%) because otherwise we keep
|
||||
polling and parser keeps parsing even though the user is nowhere near that
|
||||
part of audio
|
||||
|
||||
UPDATES FOR UI. Duration, needle ticking, playing status, etc.
|
||||
|
||||
HANDLE PLAYING. Ensure the engine is in the correct state when playing,
|
||||
pausing, or seeking
|
||||
*/
|
||||
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 let queue = DispatchQueue(label: "SwiftAudioPlayer.engine", qos: .userInitiated)
|
||||
|
||||
//From init
|
||||
private var converter: AudioConvertable!
|
||||
|
||||
//Fields
|
||||
private var currentTimeOffset: TimeInterval = 0
|
||||
|
||||
private var numberOfBuffersScheduledInTotal = 0 {
|
||||
didSet {
|
||||
Log.debug("number of buffers scheduled in total: \(numberOfBuffersScheduledInTotal)")
|
||||
if numberOfBuffersScheduledInTotal == 0 {
|
||||
pause()
|
||||
// delegate?.didError()
|
||||
// TODO: we should not have an error here. We should instead have the throttler
|
||||
// propegate when it doesn't enough buffers while they were playing
|
||||
// TODO: "Make this a legitimate warning to user about needing more data from stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wasPlaying = false
|
||||
private var numberOfBuffersScheduledFromPoll = 0 {
|
||||
didSet {
|
||||
if numberOfBuffersScheduledFromPoll > MAX_POLL_BUFFER_COUNT {
|
||||
shouldPollForNextBuffer = false
|
||||
}
|
||||
|
||||
if numberOfBuffersScheduledFromPoll > MIN_BUFFERS_TO_BE_PLAYABLE {
|
||||
if wasPlaying {
|
||||
play()
|
||||
wasPlaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldPollForNextBuffer = true {
|
||||
didSet {
|
||||
if shouldPollForNextBuffer {
|
||||
numberOfBuffersScheduledFromPoll = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Prediction keeps fluctuating. We debounce to keep the UI from jitter
|
||||
private var predictedStreamDurationDebounceHelper: Duration = 0
|
||||
private var predictedStreamDuration: Duration = 0 {
|
||||
didSet {
|
||||
let d = predictedStreamDuration
|
||||
let s = predictedStreamDurationDebounceHelper
|
||||
if d/DEBOUNCING_BUFFER_TIME != s/DEBOUNCING_BUFFER_TIME {
|
||||
predictedStreamDurationDebounceHelper = predictedStreamDuration
|
||||
duration = predictedStreamDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var seekNeedleCommandBeforeEngineWasReady: Needle?
|
||||
private var isPlayable = false {
|
||||
didSet {
|
||||
if isPlayable != oldValue {
|
||||
Log.info("isPlayable status changed: \(isPlayable)")
|
||||
}
|
||||
|
||||
if isPlayable, let needle = seekNeedleCommandBeforeEngineWasReady {
|
||||
seekNeedleCommandBeforeEngineWasReady = nil
|
||||
seek(toNeedle: needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(withRemoteUrl url: AudioURL, delegate:AudioEngineDelegate?) {
|
||||
Log.info(url)
|
||||
super.init(url: url, delegate: delegate, engineAudioFormat: AudioEngine.defaultEngineAudioFormat)
|
||||
do {
|
||||
converter = try AudioConverter(withRemoteUrl: url, toEngineAudioFormat: AudioEngine.defaultEngineAudioFormat)
|
||||
} catch {
|
||||
delegate?.didError()
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
//MARK:- Timer loop
|
||||
|
||||
//Called when
|
||||
//1. First time audio is finally parsed
|
||||
//2. When we run to the end of the network buffer and we're waiting again
|
||||
private func pollForNextBuffer() {
|
||||
guard shouldPollForNextBuffer else { return }
|
||||
|
||||
do {
|
||||
let nextScheduledBuffer = try converter.pullBuffer(withSize: PCM_BUFFER_SIZE)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: re-do how to pass and log these errors
|
||||
} catch ConverterError.reachedEndOfFile {
|
||||
Log.info(ConverterError.reachedEndOfFile.localizedDescription)
|
||||
} catch ConverterError.notEnoughData {
|
||||
Log.debug(ConverterError.notEnoughData.localizedDescription)
|
||||
} catch ConverterError.superConcerningShouldNeverHappen {
|
||||
Log.error(ConverterError.superConcerningShouldNeverHappen.localizedDescription)
|
||||
} catch {
|
||||
Log.debug(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func updateNeedle() {
|
||||
guard engine.isRunning else { return }
|
||||
|
||||
guard let nodeTime = playerNode.lastRenderTime,
|
||||
let playerTime = playerNode.playerTime(forNodeTime: nodeTime) else {
|
||||
return
|
||||
}
|
||||
|
||||
//NOTE: playerTime can sometimes be < 0 when seeking. Reason pasted below
|
||||
//"The usual AVAudioNode sample times (as observed by lastRenderTime ) have an arbitrary zero point.
|
||||
//AVAudioPlayerNode superimposes a second “player timeline” on top of this, to reflect when the
|
||||
//player was started, and intervals during which it was paused."
|
||||
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)
|
||||
}
|
||||
|
||||
private func updateDuration() {
|
||||
if let d = converter.pollPredictedDuration() {
|
||||
self.predictedStreamDuration = d
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//MARK:- Overriden From Parent
|
||||
override func seek(toNeedle needle: Needle) {
|
||||
Log.info("didSeek to needle: \(needle)")
|
||||
guard needle < (ceil(predictedStreamDuration)) else {
|
||||
if !isPlayable {
|
||||
seekNeedleCommandBeforeEngineWasReady = needle
|
||||
}
|
||||
Log.error("tried to seek beyond duration")
|
||||
return
|
||||
}
|
||||
|
||||
self.needle = needle //to tick while paused
|
||||
|
||||
queue.sync { [weak self] in
|
||||
self?.seekHelperDispatchQueue(needle: needle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
The UI would freeze when we tried to call playerNode.stop() while
|
||||
simultaneously filling a buffer on another thread. Solution was to put
|
||||
playerNode related commands in a DispatchQueue
|
||||
*/
|
||||
private func seekHelperDispatchQueue(needle: Needle) {
|
||||
wasPlaying = playerNode.isPlaying
|
||||
|
||||
//NOTE: Order matters
|
||||
//seek needs to be called before stop
|
||||
//Why? Stop will clear all buffers. Each buffer being cleared
|
||||
//will call the callback which then fills the buffers with things to the
|
||||
//right of the needle. If the order of these two were reversed we would
|
||||
//schedule things to the right of the old needle then actually schedule everything
|
||||
//after the new needle
|
||||
//We also need to poll right after the seek to give us more buffers
|
||||
converter.seek(needle)
|
||||
currentTimeOffset = TimeInterval(needle)
|
||||
|
||||
playerNode.stop()
|
||||
|
||||
shouldPollForNextBuffer = true
|
||||
|
||||
updateNetworkBufferRange()
|
||||
}
|
||||
|
||||
override func invalidate() {
|
||||
converter.invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//
|
||||
// AudioThrottler.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
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 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")"
|
||||
}
|
||||
}
|
||||
|
||||
//Init
|
||||
let url: AudioURL
|
||||
weak var delegate: AudioThrottleDelegate?
|
||||
|
||||
private var networkData: [NetworkDataWrapper] = []
|
||||
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
|
||||
|
||||
var largestPollingOffsetDifference: UInt64 = 1
|
||||
|
||||
required init(withRemoteUrl url: AudioURL, withDelegate delegate: AudioThrottleDelegate) {
|
||||
self.url = url
|
||||
self.delegate = delegate
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tellSeek(offset: UInt64) {
|
||||
Log.info("seek with offset: \(offset)")
|
||||
|
||||
if networkData.count == 0 {
|
||||
byteOffsetBecauseOfSeek = UInt(offset)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollRangeOfBytesAvailable() -> (UInt64, UInt64) {
|
||||
let start = networkData.first?.startOffset ?? 0
|
||||
let end = networkData.last?.endOffset ?? 0
|
||||
|
||||
return (UInt64(start), UInt64(end))
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
AudioDataManager.shared.deleteStream(withRemoteURL: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// AudioConverter.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
|
||||
protocol AudioConvertable {
|
||||
var engineAudioFormat: AVAudioFormat {get}
|
||||
|
||||
init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat) throws
|
||||
func pullBuffer(withSize size: AVAudioFrameCount) throws -> AVAudioPCMBuffer
|
||||
func pollPredictedDuration() -> Duration?
|
||||
func pollNetworkAudioAvailabilityRange() -> (Needle, Duration)
|
||||
func seek(_ needle: Needle)
|
||||
func invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
Creates PCM Buffers for the audio engine
|
||||
|
||||
Main Responsibilities:
|
||||
|
||||
CREATE CONVERTER. Waits for parser to give back audio format then creates a
|
||||
converter.
|
||||
|
||||
USE CONVERTER. The converter takes parsed audio packets and 1. transforms them
|
||||
into a format that the engine can take. 2. Fills a buffer of a certain size.
|
||||
Note that we might not need a converted if the format that the engine takes in
|
||||
is the same as what the parser outputs.
|
||||
|
||||
KEEP AUDIO INDEX: The engine keeps trying to pull a buffer from converter. The
|
||||
converter will keep pulling from parser. The converter calculates the exact
|
||||
index that it wants to convert and keeps pulling at that index until the parser
|
||||
passes up a value.
|
||||
*/
|
||||
class AudioConverter: AudioConvertable {
|
||||
let queue = DispatchQueue(label: "SwiftAudioPlayer.audio_reader_queue")
|
||||
|
||||
//From Init
|
||||
var parser: AudioParsable!
|
||||
|
||||
//From protocol
|
||||
public var engineAudioFormat: AVAudioFormat
|
||||
|
||||
//Field
|
||||
var converter: AudioConverterRef? //set by AudioConverterNew
|
||||
var currentAudioPacketIndex: AVAudioPacketCount = 0
|
||||
|
||||
required init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat) throws {
|
||||
self.engineAudioFormat = toEngineAudioFormat
|
||||
do {
|
||||
parser = try AudioParser(withRemoteUrl: url, parsedFileAudioFormatCallback: {
|
||||
[weak self] (fileAudioFormat: AVAudioFormat) in
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
let sourceFormat = fileAudioFormat.streamDescription
|
||||
let destinationFormat = strongSelf.engineAudioFormat.streamDescription
|
||||
let result = AudioConverterNew(sourceFormat, destinationFormat, &strongSelf.converter)
|
||||
|
||||
guard result == noErr else {
|
||||
Log.monitor(ConverterError.unableToCreateConverter(result).errorDescription as Any)
|
||||
return
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
throw ConverterError.failedToCreateParser
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
guard let converter = converter else {
|
||||
Log.error("No converter n deinit!")
|
||||
return
|
||||
}
|
||||
|
||||
guard AudioConverterDispose(converter) == noErr else {
|
||||
Log.monitor("failed to dispose audio converter")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func pullBuffer(withSize size: AVAudioFrameCount) 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 {
|
||||
Log.monitor(ConverterError.failedToCreatePCMBuffer.errorDescription as Any)
|
||||
throw ConverterError.failedToCreatePCMBuffer
|
||||
}
|
||||
pcmBuffer.frameLength = size
|
||||
|
||||
/**
|
||||
The whole thing is wrapped in queue.sync() because the converter listener
|
||||
needs to eventually increment the audioPatcketIndex. We don't want threads
|
||||
to mess this up
|
||||
*/
|
||||
return try queue.sync { () -> AVAudioPCMBuffer in
|
||||
let framesPerPacket = engineAudioFormat.streamDescription.pointee.mFramesPerPacket
|
||||
var numberOfPacketsWeWantTheBufferToFill = size / framesPerPacket
|
||||
|
||||
let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
|
||||
let status = AudioConverterFillComplexBuffer(converter, ConverterListener, context, &numberOfPacketsWeWantTheBufferToFill, pcmBuffer.mutableAudioBufferList, nil)
|
||||
|
||||
guard status == noErr else {
|
||||
switch status {
|
||||
case ReaderMissingSourceFormatError:
|
||||
throw ConverterError.parserMissingDataFormat
|
||||
case ReaderReachedEndOfDataError:
|
||||
throw ConverterError.reachedEndOfFile
|
||||
case ReaderNotEnoughDataError:
|
||||
throw ConverterError.notEnoughData
|
||||
case ReaderShouldNotHappenError:
|
||||
throw ConverterError.superConcerningShouldNeverHappen
|
||||
default:
|
||||
throw ConverterError.converterFailed(status)
|
||||
}
|
||||
}
|
||||
return pcmBuffer
|
||||
}
|
||||
}
|
||||
|
||||
func seek(_ needle: Needle) {
|
||||
guard let audioPacketIndex = getPacketIndex(forNeedle: needle) else {
|
||||
return
|
||||
}
|
||||
Log.info("didSeek to packet index: \(audioPacketIndex)")
|
||||
queue.sync {
|
||||
currentAudioPacketIndex = audioPacketIndex
|
||||
parser.tellSeek(toIndex: audioPacketIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func pollPredictedDuration() -> Duration? {
|
||||
return parser.predictedDuration
|
||||
}
|
||||
|
||||
func pollNetworkAudioAvailabilityRange() -> (Needle, Duration) {
|
||||
return parser.pollRangeOfSecondsAvailableFromNetwork()
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
parser.invalidate()
|
||||
}
|
||||
|
||||
private func getPacketIndex(forNeedle needle: Needle) -> AVAudioPacketCount? {
|
||||
guard needle >= 0 else {
|
||||
Log.error("needle should never be a negative number! needle received: \(needle)")
|
||||
return nil
|
||||
}
|
||||
guard let frame = frameOffset(forTime: TimeInterval(needle)) else { return nil }
|
||||
guard let framesPerPacket = parser.fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else { return nil }
|
||||
return AVAudioPacketCount(frame) / AVAudioPacketCount(framesPerPacket)
|
||||
}
|
||||
|
||||
private func frameOffset(forTime time: TimeInterval) -> AVAudioFramePosition? {
|
||||
guard let _ = parser.fileAudioFormat?.streamDescription.pointee, let frameCount = parser.totalPredictedAudioFrameCount, let duration = parser.predictedDuration else { return nil }
|
||||
let ratio = time / duration
|
||||
return AVAudioFramePosition(Double(frameCount) * ratio)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// AudioConverterErrors.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
|
||||
|
||||
let ReaderReachedEndOfDataError: OSStatus = 932332581
|
||||
let ReaderNotEnoughDataError: OSStatus = 932332582
|
||||
let ReaderMissingSourceFormatError: OSStatus = 932332583
|
||||
let ReaderMissingParserError: OSStatus = 932332584
|
||||
let ReaderShouldNotHappenError: OSStatus = 932332585
|
||||
|
||||
public enum ConverterError: LocalizedError {
|
||||
case cannotLockQueue
|
||||
case converterFailed(OSStatus)
|
||||
case cannotCreatePCMBufferWithoutConverter
|
||||
case failedToCreateDestinationFormat
|
||||
case failedToCreatePCMBuffer
|
||||
case notEnoughData
|
||||
case parserMissingDataFormat
|
||||
case reachedEndOfFile
|
||||
case unableToCreateConverter(OSStatus)
|
||||
case superConcerningShouldNeverHappen
|
||||
case throttleParsingBuffersForEngine
|
||||
case failedToCreateParser
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .cannotLockQueue:
|
||||
return "Failed to lock queue"
|
||||
case .converterFailed(let status):
|
||||
return localizedDescriptionFromConverterError(status)
|
||||
case .failedToCreateDestinationFormat:
|
||||
return "Failed to create a destination (processing) format"
|
||||
case .failedToCreatePCMBuffer:
|
||||
return "Failed to create PCM buffer for reading data"
|
||||
case .notEnoughData:
|
||||
return "Not enough data for read-conversion operation"
|
||||
case .parserMissingDataFormat:
|
||||
return "Parser is missing a valid data format"
|
||||
case .reachedEndOfFile:
|
||||
return "Reached the end of the file"
|
||||
case .unableToCreateConverter(let status):
|
||||
return localizedDescriptionFromConverterError(status)
|
||||
case .superConcerningShouldNeverHappen:
|
||||
return "Weird unexpected reader error. Should not have happened"
|
||||
case .cannotCreatePCMBufferWithoutConverter:
|
||||
return "Could not create a PCM Buffer because reader does not have a converter yet"
|
||||
case .throttleParsingBuffersForEngine:
|
||||
return "Preventing the reader from creating more PCM buffers since the player has more than 60 seconds of audio already to play"
|
||||
case .failedToCreateParser:
|
||||
return "Could not create a parser"
|
||||
}
|
||||
}
|
||||
|
||||
func localizedDescriptionFromConverterError(_ status: OSStatus) -> String {
|
||||
switch status {
|
||||
case kAudioConverterErr_FormatNotSupported:
|
||||
return "Format not supported"
|
||||
case kAudioConverterErr_OperationNotSupported:
|
||||
return "Operation not supported"
|
||||
case kAudioConverterErr_PropertyNotSupported:
|
||||
return "Property not supported"
|
||||
case kAudioConverterErr_InvalidInputSize:
|
||||
return "Invalid input size"
|
||||
case kAudioConverterErr_InvalidOutputSize:
|
||||
return "Invalid output size"
|
||||
case kAudioConverterErr_BadPropertySizeError:
|
||||
return "Bad property size error"
|
||||
case kAudioConverterErr_RequiresPacketDescriptionsError:
|
||||
return "Requires packet descriptions"
|
||||
case kAudioConverterErr_InputSampleRateOutOfRange:
|
||||
return "Input sample rate out of range"
|
||||
case kAudioConverterErr_OutputSampleRateOutOfRange:
|
||||
return "Output sample rate out of range"
|
||||
case kAudioConverterErr_HardwareInUse:
|
||||
return "Hardware is in use"
|
||||
case kAudioConverterErr_NoHardwarePermission:
|
||||
return "No hardware permission"
|
||||
default:
|
||||
return "Unspecified error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// AudioConverterListener.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
|
||||
func ConverterListener(_ converter: AudioConverterRef, _ packetCount: UnsafeMutablePointer<UInt32>, _ ioData: UnsafeMutablePointer<AudioBufferList>, _ outPacketDescriptions: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?, _ context: UnsafeMutableRawPointer?) -> OSStatus {
|
||||
let selfAudioConverter = Unmanaged<AudioConverter>.fromOpaque(context!).takeUnretainedValue()
|
||||
|
||||
guard let parser = selfAudioConverter.parser else {
|
||||
Log.monitor("ReaderMissingParserError")
|
||||
return ReaderMissingParserError
|
||||
}
|
||||
|
||||
guard let fileAudioFormat = parser.fileAudioFormat else {
|
||||
Log.monitor("ReaderMissingSourceFormatError")
|
||||
return ReaderMissingSourceFormatError
|
||||
}
|
||||
|
||||
var audioPacketFromParser:(AudioStreamPacketDescription?, Data)?
|
||||
do {
|
||||
audioPacketFromParser = try parser.pullPacket(atIndex: selfAudioConverter.currentAudioPacketIndex)
|
||||
Log.debug("received packet from parser at index: \(selfAudioConverter.currentAudioPacketIndex)")
|
||||
} catch ParserError.notEnoughDataForReader {
|
||||
return ReaderNotEnoughDataError
|
||||
} catch ParserError.readerAskingBeyondEndOfFile {
|
||||
//On output, the number of packets of audio data provided for conversion,
|
||||
//or 0 if there is no more data to convert.
|
||||
packetCount.pointee = 0
|
||||
return ReaderReachedEndOfDataError
|
||||
} catch {
|
||||
return ReaderShouldNotHappenError
|
||||
}
|
||||
|
||||
guard let audioPacket = audioPacketFromParser else {
|
||||
return ReaderShouldNotHappenError
|
||||
}
|
||||
|
||||
// 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
|
||||
memcpy((ioData.pointee.mBuffers.mData?.assumingMemoryBound(to: UInt8.self))!, bytes, packetByteCount)
|
||||
})
|
||||
ioData.pointee.mBuffers.mDataByteSize = UInt32(packetByteCount)
|
||||
|
||||
// Handle packet descriptions for compressed formats (MP3, AAC, etc)
|
||||
let fileFormatDescription = fileAudioFormat.streamDescription.pointee
|
||||
if fileFormatDescription.mFormatID != kAudioFormatLinearPCM {
|
||||
if outPacketDescriptions?.pointee == nil {
|
||||
outPacketDescriptions?.pointee = UnsafeMutablePointer<AudioStreamPacketDescription>.allocate(capacity: 1)
|
||||
}
|
||||
outPacketDescriptions?.pointee?.pointee.mDataByteSize = UInt32(packetByteCount)
|
||||
outPacketDescriptions?.pointee?.pointee.mStartOffset = 0
|
||||
outPacketDescriptions?.pointee?.pointee.mVariableFramesInPacket = 0
|
||||
}
|
||||
|
||||
packetCount.pointee = 1
|
||||
|
||||
//we've successfully given a packet to the LPCM buffer now we can process the next audio packet
|
||||
selfAudioConverter.currentAudioPacketIndex = selfAudioConverter.currentAudioPacketIndex + 1
|
||||
|
||||
return noErr
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// AudioParsable.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
|
||||
protocol AudioParsable { //For the layer above us
|
||||
var fileAudioFormat: AVAudioFormat? {get}
|
||||
var totalPredictedPacketCount: AVAudioPacketCount { get }
|
||||
func tellSeek(toIndex index: AVAudioPacketCount)
|
||||
func pollRangeOfSecondsAvailableFromNetwork() -> (Needle, Duration)
|
||||
func pullPacket(atIndex index: AVAudioPacketCount) throws -> (AudioStreamPacketDescription?, Data)
|
||||
func invalidate() //deinit caused concurrency problems
|
||||
}
|
||||
|
||||
extension AudioParsable { //For the layer above us
|
||||
var predictedDuration: Duration? {
|
||||
guard let sampleRate = fileAudioFormat?.sampleRate else { return nil }
|
||||
guard let totalPredictedFrameCount = totalPredictedAudioFrameCount else { return nil }
|
||||
return Duration(totalPredictedFrameCount)/Duration(sampleRate)
|
||||
}
|
||||
|
||||
|
||||
var totalPredictedAudioFrameCount: AUAudioFrameCount? {
|
||||
guard let framesPerPacket = fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else {return nil }
|
||||
return AVAudioFrameCount(totalPredictedPacketCount) * AVAudioFrameCount(framesPerPacket)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//
|
||||
// AudioParser.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
|
||||
/**
|
||||
DEFINITIONS
|
||||
|
||||
An audio stream is a continuous series of data that represents a sound, such as a song.
|
||||
|
||||
A channel is a discrete track of monophonic audio. A monophonic stream has one channel; a stereo stream has two channels.
|
||||
|
||||
A sample is single numerical value for a single audio channel in an audio stream.
|
||||
|
||||
A frame is a collection of time-coincident samples. For instance, a linear PCM stereo sound file has two samples per frame, one for the left channel and one for the right channel.
|
||||
|
||||
A packet is a collection of one or more contiguous frames. A packet defines the smallest meaningful set of frames for a given audio data format, and is the smallest data unit for which time can be measured. In linear PCM audio, a packet holds a single frame. In compressed formats, it typically holds more; in some formats, the number of frames per packet varies.
|
||||
|
||||
The sample rate for a stream is the number of frames per second of uncompressed (or, for compressed formats, the equivalent in decompressed) audio.
|
||||
|
||||
*/
|
||||
|
||||
|
||||
//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 {
|
||||
//MARK:- For OS parser class
|
||||
var parsedAudioHeaderPacketCount: UInt64 = 0
|
||||
var parsedAudioPacketDataSize: UInt64 = 0
|
||||
var parsedAudioDataOffset: UInt64 = 0
|
||||
var streamID: AudioFileStreamID?
|
||||
public var fileAudioFormat: AVAudioFormat? {
|
||||
didSet {
|
||||
if let format = fileAudioFormat, oldValue == nil {
|
||||
parsedFileAudioFormatCallback(format)
|
||||
throttler.tellAudioFormatFound()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//MARK:- Our vars
|
||||
//Init
|
||||
let url: AudioURL
|
||||
var throttler: AudioThrottleable!
|
||||
|
||||
//Our use
|
||||
var expectedFileSizeInBytes: UInt64?
|
||||
var networkProgress: Double = 0
|
||||
var parsedFileAudioFormatCallback: (AVAudioFormat) -> ()
|
||||
var indexSeekOffset: AVAudioPacketCount = 0
|
||||
var shouldPreventPacketFromFillingUp = false
|
||||
|
||||
public var totalPredictedPacketCount: AVAudioPacketCount {
|
||||
if parsedAudioHeaderPacketCount != 0 {
|
||||
//TODO: we should log the duration to the server for better user experience
|
||||
return max(AVAudioPacketCount(parsedAudioHeaderPacketCount), AVAudioPacketCount(audioPackets.count))
|
||||
}
|
||||
|
||||
guard let sizeOfFileInBytes = expectedFileSizeInBytes, let bytesPerPacket = averageBytesPerPacket else {
|
||||
return AVAudioPacketCount(0)
|
||||
}
|
||||
|
||||
let predictedCount = AVAudioPacketCount(Double(sizeOfFileInBytes) / bytesPerPacket)
|
||||
|
||||
guard networkProgress != 1.0 else {
|
||||
return max(AVAudioPacketCount(audioPackets.count), predictedCount)
|
||||
}
|
||||
|
||||
return predictedCount
|
||||
}
|
||||
|
||||
var sumOfParsedAudioBytes:UInt32 = 0 {
|
||||
didSet {
|
||||
if let byteCount = averageBytesPerPacket {
|
||||
throttler.tellBytesPerAudioPacket(count: UInt64(byteCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var numberOfPacketsParsed:UInt32 = 0
|
||||
var audioPackets: [(AudioStreamPacketDescription?,Data)] = [] {
|
||||
didSet {
|
||||
if let audioPacketByteSize = audioPackets.last?.0?.mDataByteSize {
|
||||
sumOfParsedAudioBytes += audioPacketByteSize
|
||||
numberOfPacketsParsed += 1
|
||||
}
|
||||
|
||||
//TODO: duration will not work with WAV or AIFF
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Audio packets varry in size. The first one parsed in a batch of audio
|
||||
packets is usually off by 1 from the others. We use the
|
||||
averageByesPerPacket for two things. 1. Predicting total audio packet count
|
||||
which is used for duration. 2. Calculate seeking spot for throttler and
|
||||
network seek. This used to be an Int but caused inacuracies for longer
|
||||
podcasts. Since Double->Int is floored the parser would ask for byte 979312
|
||||
but that spot is actually suppose to be 982280 from the throttler's perspective
|
||||
*/
|
||||
var averageBytesPerPacket:Double? {
|
||||
if numberOfPacketsParsed == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Double(sumOfParsedAudioBytes)/Double(numberOfPacketsParsed)
|
||||
}
|
||||
|
||||
var isParsingComplete: Bool {
|
||||
guard fileAudioFormat != nil else {
|
||||
return false
|
||||
}
|
||||
//TODO: will this ever return true? Predicted uses MAX of prediction of total packet length
|
||||
return audioPackets.count == totalPredictedPacketCount
|
||||
}
|
||||
|
||||
|
||||
init(withRemoteUrl url: AudioURL, parsedFileAudioFormatCallback: @escaping(AVAudioFormat) -> ()) throws {
|
||||
self.url = url
|
||||
self.parsedFileAudioFormatCallback = parsedFileAudioFormatCallback
|
||||
self.throttler = AudioThrottler(withRemoteUrl: url, withDelegate: self)
|
||||
|
||||
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 {
|
||||
throw ParserError.couldNotOpenStream
|
||||
}
|
||||
}
|
||||
|
||||
func pullPacket(atIndex index: AVAudioPacketCount) throws -> (AudioStreamPacketDescription?, Data) {
|
||||
if let offset = getOffset(fromPacketIndex: index) {
|
||||
throttler.tellByteOffset(offset: offset)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
guard let byteOffset = getOffset(fromPacketIndex: index) else {
|
||||
return
|
||||
}
|
||||
Log.info("did not have processed audio for index: \(index) / offset: \(byteOffset)")
|
||||
|
||||
indexSeekOffset = index
|
||||
|
||||
// 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 = []
|
||||
|
||||
throttler.tellSeek(offset: byteOffset)
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
return UInt64(Double(index) * bytesPerPacket) + parsedAudioDataOffset
|
||||
}
|
||||
|
||||
func pollRangeOfSecondsAvailableFromNetwork() -> (Needle, Duration) {
|
||||
let range = throttler.pollRangeOfBytesAvailable()
|
||||
|
||||
let startPacket = getPacket(fromOffset: range.0) != nil ? getPacket(fromOffset: range.0)! : 0
|
||||
|
||||
guard let startFrame = getFrame(forPacket: startPacket), let startNeedle = getNeedle(forFrame: startFrame) else {
|
||||
return (0, 0)
|
||||
}
|
||||
|
||||
guard let endPacket = getPacket(fromOffset: range.1), let endFrame = getFrame(forPacket: endPacket), let endNeedle = getNeedle(forFrame: endFrame) else {
|
||||
return (0, 0)
|
||||
}
|
||||
|
||||
return (startNeedle, Duration(endNeedle))
|
||||
}
|
||||
|
||||
private func getPacket(fromOffset offset: UInt64) -> AVAudioPacketCount? {
|
||||
guard fileAudioFormat != nil, let bytesPerPacket = self.averageBytesPerPacket else { return nil }
|
||||
let audioDataBytes = Int(offset) - Int(parsedAudioDataOffset)
|
||||
|
||||
guard audioDataBytes > 0 else { // Because we error out if we try to set a negative number as AVAudioPacketCount which is a UInt32
|
||||
return nil
|
||||
}
|
||||
|
||||
return AVAudioPacketCount(Double(audioDataBytes) / bytesPerPacket)
|
||||
}
|
||||
|
||||
private func getFrame(forPacket packet: AVAudioPacketCount) -> AVAudioFrameCount? {
|
||||
guard let framesPerPacket = fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else { return nil }
|
||||
return packet * framesPerPacket
|
||||
}
|
||||
|
||||
private func getNeedle(forFrame frame: AVAudioFrameCount) -> Needle? {
|
||||
guard let _ = fileAudioFormat?.streamDescription.pointee, let frameCount = totalPredictedAudioFrameCount, let duration = predictedDuration else { return nil }
|
||||
|
||||
guard duration > 0 else { return nil }
|
||||
|
||||
return Needle(TimeInterval(frame)/TimeInterval(frameCount)*duration)
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
throttler.invalidate()
|
||||
|
||||
//FIXME: See Note below. Don't remove this until the problem has been properly solved
|
||||
//if let sId = streamID {
|
||||
// let result = AudioFileStreamClose(sId)
|
||||
// if result != noErr {
|
||||
// Log.monitor("parser_error", ParserError.failedToParseBytes(result).errorDescription)
|
||||
// }
|
||||
//}
|
||||
/**
|
||||
We saw a bad access in the parser. We think this is because AudioFileStreamClose is called before the parser finished parsing a set of networkPackets.
|
||||
|
||||
Three solutions we thought of:
|
||||
1. Make parser a singleton and have callbacks that use and ID
|
||||
2. Do some math on network data size and parsed packets. The parsed packets get 99.9% to the network data
|
||||
3. Uncomment AudioFileStreamClose. There will be potential memory leaks
|
||||
|
||||
We chose option 3 because:
|
||||
+ we looked at memory hit and it was neglegible
|
||||
+ simplest solution
|
||||
– we might forget about commenting this out and run into a bug
|
||||
*/
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//MARK:- AudioThrottleDelegate
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// AudioParserErrors.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
|
||||
enum ParserError: LocalizedError {
|
||||
case couldNotOpenStream
|
||||
case failedToParseBytes(OSStatus)
|
||||
case notEnoughDataForReader
|
||||
case readerAskingBeyondEndOfFile
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .couldNotOpenStream:
|
||||
return "Could not open stream for parsing"
|
||||
case .failedToParseBytes(let status):
|
||||
return localizedDescriptionFromParseError(status)
|
||||
case .notEnoughDataForReader:
|
||||
return "Not enough data for reader. Will attemp to seek"
|
||||
case .readerAskingBeyondEndOfFile:
|
||||
return "Reader asking for packets beyond the end of file"
|
||||
}
|
||||
}
|
||||
|
||||
func localizedDescriptionFromParseError(_ status: OSStatus) -> String {
|
||||
switch status {
|
||||
case kAudioFileStreamError_UnsupportedFileType:
|
||||
return "The file type is not supported"
|
||||
case kAudioFileStreamError_UnsupportedDataFormat:
|
||||
return "The data format is not supported by this file type"
|
||||
case kAudioFileStreamError_UnsupportedProperty:
|
||||
return "The property is not supported"
|
||||
case kAudioFileStreamError_BadPropertySize:
|
||||
return "The size of the property data was not correct"
|
||||
case kAudioFileStreamError_NotOptimized:
|
||||
return "It is not possible to produce output packets because the file's packet table or other defining"
|
||||
case kAudioFileStreamError_InvalidPacketOffset:
|
||||
return "A packet offset was less than zero, or past the end of the file,"
|
||||
case kAudioFileStreamError_InvalidFile:
|
||||
return "The file is malformed, or otherwise not a valid instance of an audio file of its type, or is not recognized as an audio file"
|
||||
case kAudioFileStreamError_ValueUnknown:
|
||||
return "The property value is not present in this file before the audio data"
|
||||
case kAudioFileStreamError_DataUnavailable:
|
||||
return "The amount of data provided to the parser was insufficient to produce any result"
|
||||
case kAudioFileStreamError_IllegalOperation:
|
||||
return "An illegal operation was attempted"
|
||||
default:
|
||||
return "An unspecified error occurred"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// This extension just helps us print out the name of an `AudioFileStreamPropertyID`. Purely for debugging and not essential to the main functionality of the parser.
|
||||
extension AudioFileStreamPropertyID {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case kAudioFileStreamProperty_ReadyToProducePackets:
|
||||
return "Ready to produce packets"
|
||||
case kAudioFileStreamProperty_FileFormat:
|
||||
return "File format"
|
||||
case kAudioFileStreamProperty_DataFormat:
|
||||
return "Data format"
|
||||
case kAudioFileStreamProperty_AudioDataByteCount:
|
||||
return "Byte count"
|
||||
case kAudioFileStreamProperty_AudioDataPacketCount:
|
||||
return "Packet count"
|
||||
case kAudioFileStreamProperty_DataOffset:
|
||||
return "Data offset"
|
||||
case kAudioFileStreamProperty_BitRate:
|
||||
return "Bit rate"
|
||||
case kAudioFileStreamProperty_FormatList:
|
||||
return "Format list"
|
||||
case kAudioFileStreamProperty_MagicCookieData:
|
||||
return "Magic cookie"
|
||||
case kAudioFileStreamProperty_MaximumPacketSize:
|
||||
return "Max packet size"
|
||||
case kAudioFileStreamProperty_ChannelLayout:
|
||||
return "Channel layout"
|
||||
case kAudioFileStreamProperty_PacketToFrame:
|
||||
return "Packet to frame"
|
||||
case kAudioFileStreamProperty_FrameToPacket:
|
||||
return "Frame to packet"
|
||||
case kAudioFileStreamProperty_PacketToByte:
|
||||
return "Packet to byte"
|
||||
case kAudioFileStreamProperty_ByteToPacket:
|
||||
return "Byte to packet"
|
||||
case kAudioFileStreamProperty_PacketTableInfo:
|
||||
return "Packet table"
|
||||
case kAudioFileStreamProperty_PacketSizeUpperBound:
|
||||
return "Packet size upper bound"
|
||||
case kAudioFileStreamProperty_AverageBytesPerPacket:
|
||||
return "Average bytes per packet"
|
||||
case kAudioFileStreamProperty_InfoDictionary:
|
||||
return "Info dictionary"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// AudioParserPacketListener.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
|
||||
func ParserPacketListener(_ 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")
|
||||
return
|
||||
}
|
||||
|
||||
guard selfAudioParser.shouldPreventPacketFromFillingUp == false else {
|
||||
Log.error("skipping parsing packets because of seek")
|
||||
return
|
||||
}
|
||||
|
||||
//TODO refactor this after we get it working
|
||||
if isCompressed {
|
||||
for i in 0 ..< Int(packetCount) {
|
||||
let audioPacketDescription = packetDescriptions[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))
|
||||
}
|
||||
} else {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// AudioParserPropertyListener.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// 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
|
||||
// documentation of modified files from source and a copy of the Apache License 2.0
|
||||
// in the project which is under the name Credited_LICENSE.
|
||||
//
|
||||
//
|
||||
// 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
|
||||
import AVFoundation
|
||||
|
||||
|
||||
func ParserPropertyListener(_ context: UnsafeMutableRawPointer, _ streamId: AudioFileStreamID, _ propertyId: AudioFileStreamPropertyID, _ flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>) {
|
||||
let selfAudioParser = Unmanaged<AudioParser>.fromOpaque(context).takeUnretainedValue()
|
||||
|
||||
Log.info("audio file stream property: \(propertyId.description)")
|
||||
switch propertyId {
|
||||
case kAudioFileStreamProperty_DataFormat:
|
||||
var fileAudioFormat = AudioStreamBasicDescription()
|
||||
GetPropertyValue(&fileAudioFormat, streamId, propertyId)
|
||||
selfAudioParser.fileAudioFormat = AVAudioFormat(streamDescription: &fileAudioFormat)
|
||||
break
|
||||
case kAudioFileStreamProperty_AudioDataPacketCount:
|
||||
GetPropertyValue(&selfAudioParser.parsedAudioHeaderPacketCount, streamId, propertyId)
|
||||
break
|
||||
case kAudioFileStreamProperty_AudioDataByteCount:
|
||||
GetPropertyValue(&selfAudioParser.parsedAudioPacketDataSize, streamId, propertyId)
|
||||
selfAudioParser.expectedFileSizeInBytes = selfAudioParser.parsedAudioDataOffset + selfAudioParser.parsedAudioPacketDataSize
|
||||
break;
|
||||
case kAudioFileStreamProperty_DataOffset:
|
||||
GetPropertyValue(&selfAudioParser.parsedAudioDataOffset, streamId, propertyId)
|
||||
|
||||
if(selfAudioParser.parsedAudioPacketDataSize != 0) {
|
||||
selfAudioParser.expectedFileSizeInBytes = selfAudioParser.parsedAudioDataOffset + selfAudioParser.parsedAudioPacketDataSize
|
||||
}
|
||||
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
//property is like the medatada of
|
||||
func GetPropertyValue<T>(_ value: inout T, _ streamId: AudioFileStreamID, _ propertyId: AudioFileStreamPropertyID) {
|
||||
var propertySize: UInt32 = 0
|
||||
guard AudioFileStreamGetPropertyInfo(streamId, propertyId, &propertySize, nil) == noErr else {//try to get the size of the property
|
||||
Log.monitor("failed to get info for property:\(propertyId.description)")
|
||||
return
|
||||
}
|
||||
|
||||
guard AudioFileStreamGetProperty(streamId, propertyId, &propertySize, &value) == noErr else {
|
||||
Log.monitor("failed to get propery value for: \(propertyId.description)")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// SAAudioAvailabilityRange.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-02-18.
|
||||
// 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
|
||||
|
||||
//Think of it as the grey buffer line from youtube
|
||||
public struct SAAudioAvailabilityRange {
|
||||
let startingNeedle: Needle
|
||||
let durationLoadedByNetwork: Duration
|
||||
let isPlayable: Bool
|
||||
|
||||
public var startingBufferTimePositon: Double {
|
||||
get {
|
||||
return startingNeedle
|
||||
}
|
||||
}
|
||||
|
||||
public var totalDurationBuffered: Double {
|
||||
get {
|
||||
return durationLoadedByNetwork
|
||||
}
|
||||
}
|
||||
|
||||
public var isReadyForPlaying: Bool {
|
||||
get {
|
||||
return isPlayable
|
||||
}
|
||||
}
|
||||
|
||||
public func contains(_ needle: Double) -> Bool {
|
||||
return needle >= startingNeedle && (needle - startingNeedle) < durationLoadedByNetwork
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// SAPlayingStatus.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-11-24.
|
||||
// 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
|
||||
|
||||
public enum SAPlayingStatus {
|
||||
case playing
|
||||
case paused
|
||||
case buffering
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// LockScreenViewProtocol.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
// MARK: - Set up lockscreen audio controls
|
||||
// Documentation: https://developer.apple.com/documentation/avfoundation/media_assets_playback_and_editing/creating_a_basic_video_player_ios_and_tvos/controlling_background_audio
|
||||
protocol LockScreenViewProtocol {
|
||||
var skipForwardSeconds: Double { get set }
|
||||
var skipBackwardSeconds: Double { get set }
|
||||
}
|
||||
|
||||
extension LockScreenViewProtocol {
|
||||
@available(iOS 10.0, *)
|
||||
func setLockScreenInfo(withMediaInfo info: SALockScreenInfo, duration: Duration) {
|
||||
var nowPlayingInfo:[String : Any] = [:]
|
||||
|
||||
let title = info.title
|
||||
let artist = info.artist
|
||||
let releaseDate = info.releaseDate
|
||||
|
||||
// For some reason we need to set a duration here for the needle?
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = NSNumber(floatLiteral: duration)
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = title
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = artist
|
||||
//nowPlayingInfo[MPMediaItemPropertyGenre] = //maybe later when we have it
|
||||
//nowPlayingInfo[MPMediaItemPropertyIsExplicit] = //maybe later when we have it
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = artist
|
||||
nowPlayingInfo[MPMediaItemPropertyMediaType] = MPMediaType.podcast.rawValue
|
||||
nowPlayingInfo[MPMediaItemPropertyPodcastTitle] = title
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 //because default is 1.0. If we pause audio then it keeps ticking
|
||||
nowPlayingInfo[MPMediaItemPropertyReleaseDate] = Date(timeIntervalSince1970: TimeInterval(releaseDate))
|
||||
|
||||
if let artwork = info.artwork {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] =
|
||||
MPMediaItemArtwork(boundsSize: artwork.size) { size in
|
||||
return artwork
|
||||
}
|
||||
} else {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size) { size in
|
||||
return UIImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button
|
||||
func setLockScreenControls(presenter: SAPlayerPresenter) { //FIXME: this is weird
|
||||
// Get the shared MPRemoteCommandCenter
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
// Add handler for Play Command
|
||||
commandCenter.playCommand.addTarget { [weak presenter] event in
|
||||
guard let presenter = presenter else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
if !presenter.getIsPlaying() {
|
||||
presenter.handlePlay()
|
||||
return .success
|
||||
}
|
||||
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
// Add handler for Pause Command
|
||||
commandCenter.pauseCommand.addTarget { [weak presenter] event in
|
||||
guard let presenter = presenter else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
if presenter.getIsPlaying() {
|
||||
presenter.handlePause()
|
||||
return .success
|
||||
}
|
||||
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [skipBackwardSeconds] as [NSNumber]
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [skipForwardSeconds] as [NSNumber]
|
||||
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak presenter] event in
|
||||
guard let presenter = presenter else {
|
||||
return .commandFailed
|
||||
}
|
||||
presenter.handleSkipBackward()
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.skipForwardCommand.addTarget { [weak presenter] event in
|
||||
guard let presenter = presenter else {
|
||||
return .commandFailed
|
||||
}
|
||||
presenter.handleSkipForward()
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak presenter] event in
|
||||
guard let presenter = presenter else {
|
||||
return .commandFailed
|
||||
}
|
||||
if let positionEvent = event as? MPChangePlaybackPositionCommandEvent {
|
||||
presenter.handleSeek(toNeedle: Needle(positionEvent.positionTime))
|
||||
return .success
|
||||
}
|
||||
|
||||
return .commandFailed
|
||||
}
|
||||
}
|
||||
|
||||
func updateLockscreenElapsedTime(needle: Needle) {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: Double(needle))
|
||||
}
|
||||
|
||||
func updateLockscreenPlaybackDuration(duration: Duration) {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
|
||||
}
|
||||
|
||||
func updateLockscreenPaused(){
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
|
||||
}
|
||||
|
||||
func updateLockscreenPlaying(){
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 1.0
|
||||
}
|
||||
|
||||
func updateLockscreenChangePlaybackRate(speed: Double){
|
||||
if speed > 0.0{
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = speed
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// AudioDataManager.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
protocol AudioDataManagable {
|
||||
var numberOfQueued: Int { get }
|
||||
var numberOfActive: Int { get }
|
||||
|
||||
var allowCellular: Bool { get set }
|
||||
|
||||
func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ())
|
||||
|
||||
func clear()
|
||||
|
||||
//Director pattern
|
||||
func attach(callback: @escaping (_ id: ID, _ progress: Double)->())
|
||||
|
||||
func startStream(withRemoteURL url: AudioURL, callback: @escaping (StreamProgressPTO) -> ()) //called by throttler
|
||||
func pauseStream(withRemoteURL url: AudioURL)
|
||||
func resumeStream(withRemoteURL url: AudioURL)
|
||||
func seekStream(withRemoteURL url: AudioURL, toByteOffset offset: UInt64)
|
||||
func deleteStream(withRemoteURL url: AudioURL)
|
||||
|
||||
func getPersistedUrl(withRemoteURL url: AudioURL) -> URL?
|
||||
func startDownload(withRemoteURL url: AudioURL, completion: @escaping (URL) -> ())
|
||||
func cancelDownload(withRemoteURL url: AudioURL)
|
||||
func deleteDownload(withLocalURL url: URL)
|
||||
}
|
||||
|
||||
class AudioDataManager: AudioDataManagable {
|
||||
var allowCellular: Bool = false
|
||||
|
||||
static let shared: AudioDataManagable = AudioDataManager()
|
||||
|
||||
// When we're streaming we want to stagger the size of data push up from disk to prevent the phone from freezing. We push up data of this chunk size every couple milliseconds.
|
||||
private let MAXIMUM_DATA_SIZE_TO_PUSH = 37744
|
||||
private let TIME_IN_BETWEEN_STREAM_DATA_PUSH = 198
|
||||
|
||||
var backgroundCompletion: ()-> Void = {} // set by AppDelegate
|
||||
|
||||
//This is the first case where a DAO passes a closure to a singleon that receives delegate calls from the OS. When the delegate from the OS is called, this class calls the DAO's closure. We pretty much set up a stream from the delegate call to the director (and all the items subscribed to that director)
|
||||
private var globalDownloadProgressCallback: (String, Double)-> Void = {_,_ in }
|
||||
|
||||
private var downloadWorker: AudioDataDownloadable!
|
||||
private var streamWorker: AudioDataStreamable!
|
||||
|
||||
private var streamingCallbacks = [(ID, (StreamProgressPTO)->())]()
|
||||
|
||||
private var originalDataCountForDownloadedAudio = 0
|
||||
|
||||
var numberOfQueued: Int {
|
||||
return downloadWorker.numberOfQueued
|
||||
}
|
||||
|
||||
var numberOfActive: Int {
|
||||
return downloadWorker.numberOfActive
|
||||
}
|
||||
|
||||
private init() {
|
||||
downloadWorker = AudioDownloadWorker(
|
||||
allowCellular: allowCellular,
|
||||
progressCallback: downloadProgressListener,
|
||||
doneCallback: downloadDoneListener,
|
||||
backgroundDownloadCallback: backgroundCompletion)
|
||||
|
||||
streamWorker = AudioStreamWorker(
|
||||
progressCallback: streamProgressListener,
|
||||
doneCallback: streamDoneListener)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
streamingCallbacks = []
|
||||
}
|
||||
|
||||
func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ()) {
|
||||
backgroundCompletion = completionHandler
|
||||
}
|
||||
|
||||
func attach(callback: @escaping (_ id: ID, _ progress: Double)->()) {
|
||||
globalDownloadProgressCallback = callback
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- Streaming
|
||||
extension AudioDataManager {
|
||||
func startStream(withRemoteURL url: AudioURL, callback: @escaping (StreamProgressPTO) -> ()) {
|
||||
if let data = FileStorage.Audio.read(url.key) {
|
||||
let dto = StreamProgressDTO.init(progress: 1.0, data: data, totalBytesExpected: Int64(data.count))
|
||||
callback(StreamProgressPTO(dto: dto))
|
||||
return
|
||||
}
|
||||
|
||||
let exists = streamingCallbacks.contains { (cb: (ID, (StreamProgressPTO) -> ())) -> Bool in
|
||||
return cb.0 == url.key
|
||||
}
|
||||
|
||||
if !exists {
|
||||
streamingCallbacks.append((url.key, callback))
|
||||
}
|
||||
|
||||
downloadWorker.stop(withID: url.key) { [weak self] (fetchedData: Data?, totalBytesExpected: Int64?) in
|
||||
self?.downloadWorker.pauseAllActive()
|
||||
self?.streamWorker.start(withID: url.key, withRemoteURL: url, withInitialData: fetchedData, andTotalBytesExpectedPreviously: totalBytesExpected)
|
||||
}
|
||||
}
|
||||
|
||||
func pauseStream(withRemoteURL url: AudioURL) {
|
||||
guard streamWorker.getRunningID() == url.key else { return }
|
||||
streamWorker.pause(withId: url.key)
|
||||
}
|
||||
|
||||
func resumeStream(withRemoteURL url: AudioURL) {
|
||||
streamWorker.resume(withId: url.key)
|
||||
}
|
||||
func seekStream(withRemoteURL url: AudioURL, toByteOffset offset: UInt64) {
|
||||
streamWorker.seek(withId: url.key, withByteOffset: offset)
|
||||
}
|
||||
|
||||
func deleteStream(withRemoteURL url: AudioURL) {
|
||||
streamWorker.stop(withId: url.key)
|
||||
streamingCallbacks.removeAll { (cb: (ID, (StreamProgressPTO) -> ())) -> Bool in
|
||||
return cb.0 == url.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- Download
|
||||
extension AudioDataManager {
|
||||
func getPersistedUrl(withRemoteURL url: AudioURL) -> URL? {
|
||||
return FileStorage.Audio.locate(url.key)
|
||||
}
|
||||
|
||||
func startDownload(withRemoteURL url: AudioURL, completion: @escaping (URL) -> ()) {
|
||||
let key = url.key
|
||||
|
||||
if let savedUrl = FileStorage.Audio.locate(key), FileStorage.Audio.isStored(key) {
|
||||
globalDownloadProgressCallback(key, 1.0)
|
||||
completion(savedUrl)
|
||||
return
|
||||
}
|
||||
|
||||
if let currentProgress = downloadWorker.getProgressOfDownload(withID: key) {
|
||||
globalDownloadProgressCallback(key, currentProgress)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: check if we already streaming and convert streaming to download when we have persistent play button
|
||||
guard streamWorker.getRunningID() != key else {
|
||||
Log.debug("already streaming audio, don't need to download key: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
downloadWorker.start(withID: key, withRemoteUrl: url, completion: completion)
|
||||
}
|
||||
|
||||
func cancelDownload(withRemoteURL url: AudioURL) {
|
||||
downloadWorker.stop(withID: url.key, callback: nil)
|
||||
FileStorage.Audio.delete(url.key)
|
||||
}
|
||||
|
||||
func deleteDownload(withLocalURL url: URL) {
|
||||
FileStorage.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- Listeners
|
||||
extension AudioDataManager {
|
||||
private func downloadProgressListener(id: ID, progress: Double) {
|
||||
globalDownloadProgressCallback(id, progress)
|
||||
}
|
||||
|
||||
private func streamProgressListener(id: ID, dto: StreamProgressDTO) {
|
||||
for c in streamingCallbacks {
|
||||
if c.0 == id {
|
||||
c.1(StreamProgressPTO(dto: dto))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadDoneListener(id: ID, error: Error?) {
|
||||
if error != nil {
|
||||
return
|
||||
}
|
||||
|
||||
globalDownloadProgressCallback(id, 1.0)
|
||||
}
|
||||
|
||||
private func streamDoneListener(id: ID, error: Error?) -> Bool {
|
||||
if error != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
downloadWorker.resumeAllActive()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//
|
||||
// AudioDownloadWorker.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
protocol AudioDataDownloadable: AnyObject {
|
||||
init(allowCellular: Bool, progressCallback: @escaping (_ id: ID, _ progress: Double)->(), doneCallback: @escaping (_ id: ID, _ error: Error?)->(), backgroundDownloadCallback: @escaping ()->())
|
||||
|
||||
var numberOfActive: Int { get }
|
||||
var numberOfQueued: Int { get }
|
||||
|
||||
func getProgressOfDownload(withID id: ID) -> Double?
|
||||
|
||||
func start(withID id: ID, withRemoteUrl remoteUrl: URL, completion: @escaping (URL) -> ())
|
||||
func stop(withID id: ID, callback: ((_ dataSoFar: Data?, _ totalBytesExpected: Int64?) -> ())?)
|
||||
func pauseAllActive() //Because of streaming
|
||||
func resumeAllActive() //Because of streaming
|
||||
}
|
||||
|
||||
class AudioDownloadWorker: NSObject, AudioDataDownloadable {
|
||||
private let MAX_CONCURRENT_DOWNLOADS = 3
|
||||
|
||||
// Given by the AppDelegate
|
||||
private let backgroundCompletion: () -> ()
|
||||
|
||||
private let progressHandler: (ID, Double) -> ()
|
||||
private let completionHandler: (ID, Error?) -> ()
|
||||
|
||||
private let allowsCellularDownload: Bool
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "SwiftAudioPlayer.background_downloader_\(Date.getUTC())")
|
||||
config.isDiscretionary = !allowsCellularDownload
|
||||
config.sessionSendsLaunchEvents = true
|
||||
config.allowsCellularAccess = allowsCellularDownload
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
private var activeDownloads: [ActiveDownload] = []
|
||||
private var queuedDownloads = Set<DownloadInfo>()
|
||||
|
||||
var numberOfActive: Int {
|
||||
return activeDownloads.count
|
||||
}
|
||||
|
||||
var numberOfQueued: Int {
|
||||
return queuedDownloads.count
|
||||
}
|
||||
|
||||
required init(allowCellular: Bool,
|
||||
progressCallback: @escaping (_ id: ID, _ progress: Double)->(),
|
||||
doneCallback: @escaping (_ id: ID, _ error: Error?)->(),
|
||||
backgroundDownloadCallback: @escaping ()->()) {
|
||||
Log.info("init with allowCellular: \(allowCellular)")
|
||||
self.progressHandler = progressCallback
|
||||
self.completionHandler = doneCallback
|
||||
self.backgroundCompletion = backgroundDownloadCallback
|
||||
self.allowsCellularDownload = allowCellular
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
func getProgressOfDownload(withID id: ID) -> Double? {
|
||||
return activeDownloads.filter { $0.info.id == id }.first?.progress
|
||||
}
|
||||
|
||||
func start(withID id: ID, withRemoteUrl remoteUrl: URL, completion: @escaping (URL) -> ()) {
|
||||
Log.info("startExternal paramID: \(id) activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)")
|
||||
let temp = activeDownloads.filter { $0.info.id == id }.count
|
||||
guard temp == 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let info = queuedDownloads.updatePreservingOldCompletionHandlers(withID: id, withRemoteUrl: remoteUrl, completion: completion)
|
||||
|
||||
start(withInfo: info)
|
||||
}
|
||||
|
||||
fileprivate func start(withInfo info: DownloadInfo) {
|
||||
Log.info("paramID: \(info.id) activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)")
|
||||
let temp = activeDownloads.filter { $0.info.id == info.id }.count
|
||||
guard temp == 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
guard numberOfActive < MAX_CONCURRENT_DOWNLOADS else {
|
||||
_ = queuedDownloads.updatePreservingOldCompletionHandlers(withID: info.id, withRemoteUrl: info.remoteUrl)
|
||||
return
|
||||
}
|
||||
|
||||
queuedDownloads.remove(info)
|
||||
|
||||
let task: URLSessionDownloadTask = session.downloadTask(with: info.remoteUrl)
|
||||
task.taskDescription = info.id
|
||||
|
||||
let activeTask = ActiveDownload(info: info, task: task)
|
||||
|
||||
activeDownloads.append(activeTask)
|
||||
activeTask.task.resume()
|
||||
}
|
||||
|
||||
func pauseAllActive() {
|
||||
Log.info("activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)")
|
||||
for download in activeDownloads {
|
||||
if download.task.state == .running {
|
||||
download.task.suspend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resumeAllActive() {
|
||||
Log.info("activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)")
|
||||
for download in activeDownloads {
|
||||
download.task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func stop(withID id: ID, callback: ((_ dataSoFar: Data?, _ totalBytesExpected: Int64?) -> ())?) {
|
||||
Log.info("paramId: \(id), activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)")
|
||||
for download in activeDownloads {
|
||||
if download.info.id == id && download.task.state == .running {
|
||||
download.task.cancel { (data: Data?) in
|
||||
callback?(nil, nil)
|
||||
// Could not achieve this because this resume data isn't actually the data downloaded so far but instead metadata. Not sure how to get the actual data that download task is downloading
|
||||
// callback?(data, download.totalBytesExpected)
|
||||
}
|
||||
activeDownloads = activeDownloads.filter { $0.info.id != id }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
queuedDownloads.remove(withMatchingId: id)
|
||||
callback?(nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioDownloadWorker: URLSessionDownloadDelegate {
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
let activeTask = activeDownloads.filter { $0.task == downloadTask }.first
|
||||
|
||||
guard let task = activeTask else {
|
||||
Log.monitor("could not find corresponding active download task when done downloading: \(downloadTask.currentRequest?.url?.absoluteString ?? "nil url")")
|
||||
return
|
||||
}
|
||||
|
||||
guard let fileType = downloadTask.response?.suggestedFilename?.pathExtension else {
|
||||
Log.monitor("No file type exists for file from downloading.. id: \(downloadTask.taskDescription ?? "nil") :: url: \(task.info.remoteUrl) where it suggested filename: \(downloadTask.response?.suggestedFilename ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
let destinationUrl = FileStorage.Audio.getUrl(givenId: task.info.id, andFileExtension: fileType)
|
||||
Log.info("Writing download file with id: \(task.info.id) to file named: \(destinationUrl.lastPathComponent)")
|
||||
|
||||
// https://stackoverflow.com/questions/20251432/cant-move-file-after-background-download-no-such-file
|
||||
// Apparently, the data of the temporary location get deleted outside of this function immediately, so others recommended extracting the data and writing it, this is why I'm not using DiskUtil
|
||||
do {
|
||||
_ = try FileManager.default.replaceItemAt(destinationUrl, withItemAt: location)
|
||||
|
||||
Log.info("Successful write file to url: \(destinationUrl.absoluteString)")
|
||||
progressHandler(task.info.id, 1.0)
|
||||
} catch {
|
||||
if (error as NSError).code == NSFileWriteFileExistsError {
|
||||
do {
|
||||
Log.info("File already existed at attempted download url: \(destinationUrl.absoluteString)")
|
||||
try FileManager.default.removeItem(at: destinationUrl)
|
||||
_ = try FileManager.default.replaceItemAt(destinationUrl, withItemAt: location)
|
||||
Log.info("Replaced previous file at url: \(destinationUrl.absoluteString)")
|
||||
} catch {
|
||||
Log.monitor("Error moving file after download for task id: \(task.info.id) and error: \(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
Log.monitor("Error moving file after download for task id: \(task.info.id) and error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(task.info.id, nil)
|
||||
|
||||
for handler in task.info.completionHandlers {
|
||||
handler(destinationUrl)
|
||||
}
|
||||
|
||||
activeDownloads = activeDownloads.filter { $0 != task }
|
||||
|
||||
if let queued = queuedDownloads.popHighestRanked() {
|
||||
start(withInfo: queued)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let e = error {
|
||||
if let err: NSError = error as NSError? {
|
||||
if err.domain == NSURLErrorDomain && err.code == NSURLErrorCancelled {
|
||||
Log.info("cancelled downloading")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let err: NSError = error as NSError? {
|
||||
if err.domain == NSPOSIXErrorDomain && err.code == 2 {
|
||||
Log.error("download error where file says it doesn't exist, this could be because of bad network")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for download in activeDownloads {
|
||||
if download.task == task {
|
||||
completionHandler(download.info.id, e)
|
||||
activeDownloads = activeDownloads.filter { $0.task != task }
|
||||
}
|
||||
}
|
||||
|
||||
Log.monitor("\(task.currentRequest?.url?.absoluteString ?? "nil url") error: \(e.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
var found: Bool = false
|
||||
|
||||
for download in activeDownloads {
|
||||
if download.task == downloadTask {
|
||||
found = true
|
||||
download.progress = Double(totalBytesWritten)/Double(totalBytesExpectedToWrite)
|
||||
download.totalBytesExpected = totalBytesExpectedToWrite
|
||||
if download.progress != 1.0 {
|
||||
progressHandler(download.info.id, download.progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
Log.monitor("could not find active download when receiving progress updates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- Helpers
|
||||
extension AudioDownloadWorker {
|
||||
}
|
||||
|
||||
// MARK:- Helper Classes
|
||||
extension AudioDownloadWorker {
|
||||
fileprivate struct DownloadInfo: Hashable {
|
||||
static func == (lhs: AudioDownloadWorker.DownloadInfo, rhs: AudioDownloadWorker.DownloadInfo) -> Bool {
|
||||
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) -> ()]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let info: DownloadInfo
|
||||
var totalBytesExpected: Int64?
|
||||
var progress: Double = 0.0
|
||||
let task: URLSessionDownloadTask
|
||||
|
||||
init(info: DownloadInfo, task: URLSessionDownloadTask) {
|
||||
self.info = info
|
||||
self.task = task
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Set where Element == AudioDownloadWorker.DownloadInfo {
|
||||
mutating func popHighestRanked() -> AudioDownloadWorker.DownloadInfo? {
|
||||
guard self.count > 0 else { return nil }
|
||||
|
||||
var ret: AudioDownloadWorker.DownloadInfo = self.first!
|
||||
|
||||
for info in self {
|
||||
if info.rank > ret.rank {
|
||||
ret = info
|
||||
}
|
||||
}
|
||||
|
||||
self.remove(ret)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
mutating func updatePreservingOldCompletionHandlers(withID id: ID, withRemoteUrl remoteUrl: URL, completion: ((URL) -> ())? = nil) -> AudioDownloadWorker.DownloadInfo {
|
||||
|
||||
let rank = Date.getUTC()
|
||||
|
||||
let tempHandlers: [(URL) -> ()] = completion != nil ? [completion!] : []
|
||||
|
||||
var newInfo = AudioDownloadWorker.DownloadInfo.init(id: id, remoteUrl: remoteUrl, rank: rank, completionHandlers: tempHandlers)
|
||||
|
||||
if let previous = self.update(with: newInfo) {
|
||||
let prevHandlers = previous.completionHandlers
|
||||
let newHandlers = prevHandlers + tempHandlers
|
||||
|
||||
newInfo = AudioDownloadWorker.DownloadInfo.init(id: id, remoteUrl: remoteUrl, rank: rank, completionHandlers: newHandlers)
|
||||
|
||||
self.update(with: newInfo)
|
||||
}
|
||||
|
||||
return newInfo
|
||||
}
|
||||
|
||||
mutating func remove(withMatchingId id: ID) {
|
||||
var toRemove: AudioDownloadWorker.DownloadInfo? = nil
|
||||
var matchCount = 0
|
||||
|
||||
for item in self.enumerated() {
|
||||
if item.element.id == id {
|
||||
toRemove = item.element
|
||||
matchCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard matchCount <= 1 else {
|
||||
Log.error("Found \(matchCount) matches of queued info with the same id of: \(id), this should have never happened.")
|
||||
return
|
||||
}
|
||||
|
||||
if let removeInfo = toRemove {
|
||||
self.remove(removeInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var pathExtension: String? {
|
||||
let cleaned = self.replacingOccurrences(of: " ", with: "_")
|
||||
let ext = URL(string: cleaned)?.pathExtension
|
||||
return ext == "" ? nil : ext
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// FileStorage.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
/**
|
||||
Utility class to access audio files saved on the phone.
|
||||
*/
|
||||
struct FileStorage {
|
||||
private init() {}
|
||||
|
||||
/**
|
||||
Generates a URL for a file that would be saved locally.
|
||||
|
||||
Note: It is not guaranteed that the file actually exists.
|
||||
*/
|
||||
static func getUrl(givenAName name: NameFile, inDirectory dir: FileManager.SearchPathDirectory) -> URL {
|
||||
let directoryPath = NSSearchPathForDirectoriesInDomains(dir, .userDomainMask, true)[0] as String
|
||||
let url = URL(fileURLWithPath: directoryPath)
|
||||
return url.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
static func isStored(_ url: URL) -> Bool{
|
||||
// https://stackoverflow.com/questions/42897844/swift-3-0-filemanager-fileexistsatpath-always-return-false
|
||||
// When determining if a file exists, we must use .path not .absolute string!
|
||||
return FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
static func delete(_ url: URL) {
|
||||
if !isStored(url) {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch let error {
|
||||
Log.error("Could not delete a file: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- Audio
|
||||
extension FileStorage {
|
||||
struct Audio {
|
||||
private static let directory: FileManager.SearchPathDirectory = .documentDirectory
|
||||
private init() {}
|
||||
|
||||
static func isStored(_ id: ID) -> Bool {
|
||||
guard let url = locate(id)?.path else {
|
||||
return false
|
||||
}
|
||||
|
||||
//FIXME: This is an unreliable API. Maybe use a map instead?
|
||||
return FileManager.default.fileExists(atPath: url)
|
||||
}
|
||||
|
||||
static func delete(_ id: ID) {
|
||||
guard let url = locate(id) else {
|
||||
Log.warn("trying to delete audio file that doesn't exist with id: \(id)")
|
||||
return
|
||||
}
|
||||
return FileStorage.delete(url)
|
||||
}
|
||||
|
||||
static func write(_ id: ID, fileExtension: String, data: Data) {
|
||||
do {
|
||||
let url = FileStorage.getUrl(givenAName: getAudioFileName(id, fileExtension: fileExtension), inDirectory: directory)
|
||||
try data.write(to: url)
|
||||
} catch {
|
||||
Log.monitor(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func read(_ id: ID) -> Data? {
|
||||
guard let url = locate(id) else {
|
||||
Log.debug("Trying to get data for audio file that doesn't exist: \(id)")
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url)
|
||||
return data
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getUrl(givenId id: ID, andFileExtension fileExtension: String) -> URL {
|
||||
let url = FileStorage.getUrl(givenAName: getAudioFileName(id, fileExtension: fileExtension), inDirectory: directory)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func getAudioFileName(_ id: ID, fileExtension: String) -> NameFile {
|
||||
return "\(id).\(fileExtension)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// StreamProgressPTO.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
|
||||
struct StreamProgressPTO {
|
||||
let dto: StreamProgressDTO
|
||||
|
||||
func getProgress() -> Double {
|
||||
return dto.progress
|
||||
}
|
||||
|
||||
func getData() -> Data {
|
||||
return dto.data
|
||||
}
|
||||
|
||||
func getTotalBytesExpected() -> Int64? {
|
||||
return dto.totalBytesExpected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//
|
||||
// AudioStreamWorker.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
/**
|
||||
init task
|
||||
+
|
||||
|
|
||||
|
|
||||
+-----v-----+ suspend() +---------+ +-----------+
|
||||
| suspended <-----------------> running +----------> completed |
|
||||
+-----+-----+ resume() +----+----+ +-----------+
|
||||
| |
|
||||
| | cancel()
|
||||
| |
|
||||
| cancel() +------v------+
|
||||
+---------------------> cancelling |
|
||||
+-------------+
|
||||
*/
|
||||
|
||||
protocol AudioDataStreamable {
|
||||
//if user taps download then starts to stream
|
||||
init(progressCallback: @escaping (_ id: ID, _ dto: StreamProgressDTO) -> (), doneCallback: @escaping (_ id: ID, _ error: Error?)->Bool) //Bool is should save or not
|
||||
func start(withID id: ID, withRemoteURL url: URL, withInitialData data: Data?, andTotalBytesExpectedPreviously previousTotalBytesExpected: Int64?)
|
||||
func pause(withId id: ID)
|
||||
func resume(withId id: ID)
|
||||
func stop(withId id: ID)//FIXME: with persistent play we should return a Data so that download can resume
|
||||
func seek(withId id: ID, withByteOffset offset: UInt64)
|
||||
func getRunningID() -> ID?
|
||||
}
|
||||
|
||||
///Policy for streaming
|
||||
///- only one stream at a time
|
||||
///- starting a stream will cancel the previous
|
||||
///- when seeking, assume that previous data is discarded
|
||||
class AudioStreamWorker:NSObject, AudioDataStreamable {
|
||||
private let TIMEOUT = 60.0
|
||||
|
||||
fileprivate let progressCallback: (_ id: ID, _ dto: StreamProgressDTO) -> ()
|
||||
//Will ony be called when the task object will no longer be active
|
||||
//Why? So upper layer knows that current streaming activity for this ID is done
|
||||
//Why? To know if we should persist the stream data assuming successful completion
|
||||
fileprivate let doneCallback: (_ id: ID, _ error: Error?) -> Bool
|
||||
private var session: URLSession!
|
||||
|
||||
private var id: ID?
|
||||
private var url: URL?
|
||||
private var task: URLSessionDataTask?
|
||||
private var previousTotalBytesExpectedFromInitalData: Int64?
|
||||
private var initialDataBytesCount: Int64 = 0
|
||||
fileprivate var totalBytesExpectedForWholeFile: Int64?
|
||||
fileprivate var totalBytesExpectedForCurrentStream: Int64?
|
||||
fileprivate var totalBytesReceived: Int64 = 0
|
||||
private var corruptedBecauseOfSeek = false
|
||||
|
||||
|
||||
/// Init
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - progressCallback: generic callback
|
||||
/// - doneCallback: when finished
|
||||
required init(progressCallback: @escaping (_ id: ID, _ dto: StreamProgressDTO) -> (), doneCallback: @escaping (_ id: ID, _ error: Error?) -> Bool) {
|
||||
self.progressCallback = progressCallback
|
||||
self.doneCallback = doneCallback
|
||||
super.init()
|
||||
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "SwiftAudioPlayer.stream")
|
||||
// Specifies that the phone should keep trying till it receives connection instead of dropping immediately
|
||||
if #available(iOS 11.0, *) {
|
||||
config.waitsForConnectivity = true
|
||||
}
|
||||
self.session = URLSession(configuration: config, delegate: self, delegateQueue: nil) //TODO: should we use ephemeral
|
||||
}
|
||||
|
||||
func start(withID id: ID, withRemoteURL url: URL, withInitialData data: Data? = nil, andTotalBytesExpectedPreviously previousTotalBytesExpected: Int64? = nil) {
|
||||
Log.info("selfID: \(self.id ?? "none"), paramID: \(id) initialData: \(data?.count ?? 0)")
|
||||
|
||||
killPreviousTaskIfNeeded()
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.previousTotalBytesExpectedFromInitalData = previousTotalBytesExpected
|
||||
|
||||
if let data = data {
|
||||
var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: TIMEOUT)
|
||||
request.addValue("bytes=\(data.count)-", forHTTPHeaderField: "Range")
|
||||
task = session.dataTask(with: request)
|
||||
task?.taskDescription = id
|
||||
|
||||
initialDataBytesCount = Int64(data.count)
|
||||
totalBytesReceived = initialDataBytesCount
|
||||
totalBytesExpectedForWholeFile = previousTotalBytesExpected
|
||||
|
||||
let progress = previousTotalBytesExpected != nil ? Double(initialDataBytesCount)/Double(previousTotalBytesExpected!) : 0
|
||||
|
||||
let dto = StreamProgressDTO(progress: progress, data: data, totalBytesExpected: totalBytesExpectedForWholeFile)
|
||||
|
||||
progressCallback(id, dto)
|
||||
|
||||
task?.resume()
|
||||
} else {
|
||||
task = session.dataTask(with: url)
|
||||
task?.resume()
|
||||
|
||||
task?.taskDescription = id
|
||||
}
|
||||
}
|
||||
|
||||
private func killPreviousTaskIfNeeded() {
|
||||
guard let task = task else {return}
|
||||
if task.state == .running || task.state == .suspended {
|
||||
task.cancel()
|
||||
}
|
||||
self.task = nil
|
||||
corruptedBecauseOfSeek = false
|
||||
totalBytesExpectedForWholeFile = nil
|
||||
totalBytesReceived = 0
|
||||
initialDataBytesCount = 0
|
||||
}
|
||||
|
||||
func pause(withId id: ID) {
|
||||
Log.info("selfID: \(self.id ?? "none"), paramID: \(id)")
|
||||
guard self.id == id else {
|
||||
Log.error("incorrect ID for command")
|
||||
return
|
||||
}
|
||||
|
||||
guard let task = task else {
|
||||
Log.error("tried to stop a non-existent task")
|
||||
return
|
||||
}
|
||||
|
||||
if task.state == .running {
|
||||
task.suspend()
|
||||
} else {
|
||||
Log.monitor("tried to pause a task that's already suspended")
|
||||
}
|
||||
}
|
||||
|
||||
func resume(withId id: ID) {
|
||||
Log.info("selfID: \(self.id ?? "none"), paramID: \(id)")
|
||||
guard self.id == id else {
|
||||
Log.error("incorrect ID for command")
|
||||
return
|
||||
}
|
||||
|
||||
guard let task = task else {
|
||||
Log.error("tried to resume a non-existent task")
|
||||
return
|
||||
}
|
||||
|
||||
if task.state == .suspended {
|
||||
task.resume()
|
||||
} else {
|
||||
Log.monitor("tried to resume a non-suspended task")
|
||||
}
|
||||
}
|
||||
|
||||
func stop(withId id: ID) {
|
||||
Log.info("selfID: \(self.id ?? "none"), paramID: \(id)")
|
||||
guard self.id == id else {
|
||||
Log.warn("incorrect ID for command")
|
||||
return
|
||||
}
|
||||
|
||||
guard let task = task else {
|
||||
Log.error("tried to stop a non-existent task")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if task.state == .running || task.state == .suspended {
|
||||
task.cancel()
|
||||
self.task = nil
|
||||
} else {
|
||||
Log.error("stream_error tried to stop a task that's in state: \(task.state.rawValue)")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func seek(withId id: ID, withByteOffset offset: UInt64) {
|
||||
Log.info("selfID: \(self.id ?? "none"), paramID: \(id), offset: \(offset)")
|
||||
guard self.id == id else {
|
||||
Log.error("incorrect ID for command")
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = url else {
|
||||
Log.monitor("tried to seek without having URL")
|
||||
return
|
||||
}
|
||||
stop(withId: id)
|
||||
totalBytesReceived = 0
|
||||
corruptedBecauseOfSeek = true
|
||||
self.progressCallback(id, StreamProgressDTO(progress: 0, data: Data(), totalBytesExpected: totalBytesExpectedForWholeFile))
|
||||
|
||||
var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: TIMEOUT)
|
||||
request.addValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||
task = session.dataTask(with: request)
|
||||
task?.resume()
|
||||
}
|
||||
|
||||
|
||||
func getRunningID() -> ID? {
|
||||
if let task = task, task.state == .running, let id = id {
|
||||
return id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//MARK:- URLSessionDataDelegate
|
||||
extension AudioStreamWorker: URLSessionDataDelegate {
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
Log.debug("selfID: ", id, " dataTaskID: ", dataTask.taskDescription, " dataSize: ", data.count, " expected: ", totalBytesExpectedForWholeFile, " received: ", totalBytesReceived)
|
||||
guard let id = id else {
|
||||
//FIXME: should be an error when done with testing phase
|
||||
Log.monitor("stream worker in weird state 9847467")
|
||||
return
|
||||
}
|
||||
|
||||
guard self.task == dataTask else {
|
||||
Log.error("stream_error not the same task") //Probably because of seek
|
||||
return
|
||||
}
|
||||
|
||||
guard let totalBytesExpected = totalBytesExpectedForCurrentStream, totalBytesExpected > 0 else {
|
||||
Log.monitor("should not be called 223r2")
|
||||
return
|
||||
}
|
||||
|
||||
totalBytesReceived = totalBytesReceived + Int64(data.count)
|
||||
let progress = Double(totalBytesReceived)/Double(totalBytesExpected)
|
||||
|
||||
Log.debug("network streaming progress \(progress)")
|
||||
self.progressCallback(id, StreamProgressDTO(progress: progress, data: data, totalBytesExpected: totalBytesExpected))
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
Log.debug(dataTask.taskDescription, id, response.description)
|
||||
guard id != nil else {
|
||||
Log.monitor("stream worker in weird state 2049jg3")
|
||||
return
|
||||
}
|
||||
|
||||
guard self.task == dataTask else {
|
||||
Log.error("stream_error not the same task")
|
||||
return
|
||||
}
|
||||
|
||||
Log.info("response length: \(response.expectedContentLength)")
|
||||
|
||||
//the value will smaller if you seek. But we want to hold the OG total for duration calculations
|
||||
if !corruptedBecauseOfSeek {
|
||||
totalBytesExpectedForWholeFile = response.expectedContentLength + initialDataBytesCount
|
||||
}
|
||||
|
||||
totalBytesExpectedForCurrentStream = response.expectedContentLength
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
Log.debug(task.taskDescription, id)
|
||||
guard let id = id else {
|
||||
Log.error("stream_error stream worker in weird state 345b45")
|
||||
return
|
||||
}
|
||||
|
||||
guard self.task == task else {
|
||||
Log.error("stream_error not the same task")
|
||||
return
|
||||
}
|
||||
|
||||
if let err: NSError = error as NSError? {
|
||||
if err.domain == NSURLErrorDomain && err.code == NSURLErrorCancelled {
|
||||
Log.info("cancelled downloading")
|
||||
let _ = doneCallback(id, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err.domain == NSURLErrorDomain && err.code == NSURLErrorNetworkConnectionLost {
|
||||
Log.error("lost connection")
|
||||
let _ = doneCallback(id, nil)
|
||||
return
|
||||
}
|
||||
|
||||
Log.monitor("\(task.currentRequest?.url?.absoluteString ?? "nil url") error: \(err.localizedDescription)")
|
||||
|
||||
let _ = doneCallback(id, err)
|
||||
}
|
||||
|
||||
let shouldSave = doneCallback(id, nil)
|
||||
if shouldSave && !corruptedBecauseOfSeek {
|
||||
// TODO want to save file after streaming so we do not have to download again
|
||||
// guard (task.response?.suggestedFilename?.pathExtension) != nil else {
|
||||
// Log.monitor("Could not determine file type for file from id: \(task.taskDescription ?? "nil") and url: \(task.currentRequest?.url?.absoluteString ?? "nil")")
|
||||
// return
|
||||
// }
|
||||
|
||||
// TODO no longer saving streamed files
|
||||
// FileStorage.Audio.write(id, fileExtension: fileType, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) {
|
||||
// TODO: Notify to user that waiting for better connection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// StreamProgressDTO.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
//Just a helper because it got too messy
|
||||
struct StreamProgressDTO {
|
||||
let progress: Double
|
||||
let data: Data
|
||||
let totalBytesExpected: Int64?
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// SALockScreenInfo.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-02-18.
|
||||
// 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
|
||||
import UIKit
|
||||
|
||||
/**
|
||||
UTC corresponds to epoch time (number of seconds that have elapsed since January 1, 1970, midnight UTC/GMT). https://www.epochconverter.com/ is a useful site to convert to human readable format.
|
||||
*/
|
||||
public typealias UTC = Int
|
||||
|
||||
/**
|
||||
Use to set what will be displayed in the lockscreen.
|
||||
*/
|
||||
public struct SALockScreenInfo {
|
||||
var title: String
|
||||
var artist: String
|
||||
var artwork: UIImage?
|
||||
var releaseDate: UTC
|
||||
|
||||
public init(title: String, artist: String, artwork: UIImage?, releaseDate: UTC) {
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.artwork = artwork
|
||||
self.releaseDate = releaseDate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// SAPlayer.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import AVFoundation
|
||||
|
||||
public class SAPlayer {
|
||||
public static let shared: SAPlayer = SAPlayer()
|
||||
private var presenter: SAPlayerPresenter!
|
||||
private var player: AudioEngine?
|
||||
|
||||
public var skipForwardSeconds: Double = 30
|
||||
public var skipBackwardSeconds: Double = 15
|
||||
|
||||
public var rate: Double = 1.0 {
|
||||
didSet {
|
||||
presenter.handleSetSpeed(withMultiple: rate)
|
||||
}
|
||||
}
|
||||
|
||||
public var duration: Double {
|
||||
get {
|
||||
return presenter.duration ?? 0.0
|
||||
}
|
||||
}
|
||||
|
||||
public var prettyDuration: String {
|
||||
get {
|
||||
return SAPlayer.prettifyTimestamp(duration)
|
||||
}
|
||||
}
|
||||
|
||||
public var elapsedTime: Double {
|
||||
get {
|
||||
return presenter.needle ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public var prettyElapsedTime: String {
|
||||
get {
|
||||
return SAPlayer.prettifyTimestamp(elapsedTime)
|
||||
}
|
||||
}
|
||||
|
||||
public var mediaInfo: SALockScreenInfo? = nil {
|
||||
didSet {
|
||||
if let info = mediaInfo {
|
||||
presenter.handleLockscreenInfo(info: info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
presenter = SAPlayerPresenter(delegate: self)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secondsLeft))"
|
||||
}
|
||||
|
||||
func getUrl(forKey key: Key) -> URL? {
|
||||
return presenter.getUrl(forKey: key)
|
||||
}
|
||||
|
||||
func addUrlToMapping(url: URL) {
|
||||
presenter.addUrlToKeyMap(url)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - External Player Controls
|
||||
extension SAPlayer {
|
||||
public func togglePlayAndPause() {
|
||||
presenter.handleTogglePlayingAndPausing()
|
||||
}
|
||||
|
||||
public func play() {
|
||||
presenter.handlePlay()
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
presenter.handlePause()
|
||||
}
|
||||
|
||||
public func skipBackwards() {
|
||||
presenter.handleSkipBackward()
|
||||
}
|
||||
|
||||
public func skipForward() {
|
||||
presenter.handleSkipForward()
|
||||
}
|
||||
|
||||
public func seekTo(seconds: Double) {
|
||||
presenter.handleSeek(toNeedle: seconds)
|
||||
}
|
||||
|
||||
public func initializeSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) {
|
||||
self.mediaInfo = mediaInfo
|
||||
presenter.handlePlaySavedAudio(withSavedUrl: url)
|
||||
}
|
||||
|
||||
public func initializeAudio(withRemoteUrl url: URL, mediaInfo: SALockScreenInfo? = nil) {
|
||||
self.mediaInfo = mediaInfo
|
||||
presenter.handlePlayStreamedAudio(withRemoteUrl: url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//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) {
|
||||
player?.pause()
|
||||
player?.invalidate()
|
||||
player = AudioStreamEngine(withRemoteUrl: url, delegate: presenter)
|
||||
}
|
||||
|
||||
func playEngine() {
|
||||
becomeDeviceAudioPlayer()
|
||||
player?.play()
|
||||
}
|
||||
|
||||
//Start taking control as the device's player
|
||||
private func becomeDeviceAudioPlayer() {
|
||||
do {
|
||||
if #available(iOS 11.0, *) {
|
||||
// try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, policy: .longForm, 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().setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
Log.monitor("Problem setting up AVAudioSession to play in:: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func pauseEngine() {
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
func seekEngine(toNeedle needle: Needle) {
|
||||
var seekToNeedle = needle < 0 ? 0 : needle
|
||||
seekToNeedle = needle > Needle(duration) ? Needle(duration) : needle
|
||||
player?.seek(toNeedle: seekToNeedle)
|
||||
}
|
||||
|
||||
func setSpeedEngine(withMultiple multiple: Double) {
|
||||
player?.setSpeed(speed: multiple)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper function inserted by Swift 4.2 migrator.
|
||||
fileprivate func convertFromAVAudioSessionMode(_ input: AVAudioSession.Mode) -> String {
|
||||
return input.rawValue
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// SAPlayerDelegate.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import CoreMedia
|
||||
|
||||
protocol SAPlayerDelegate: AnyObject, LockScreenViewProtocol {
|
||||
var skipForwardSeconds: Double { get set }
|
||||
var skipBackwardSeconds: Double { get set }
|
||||
|
||||
func startAudioDownloaded(withSavedUrl url: AudioURL)
|
||||
func startAudioStreamed(withRemoteUrl url: AudioURL)
|
||||
func playEngine()
|
||||
func pauseEngine()
|
||||
func seekEngine(toNeedle needle: Needle) //TODO ensure that engine cleans up out of bounds
|
||||
func setSpeedEngine(withMultiple multiple: Double)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// SAPlayerDownloader.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-02-25.
|
||||
// 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 SAPlayer {
|
||||
/**
|
||||
Actions relating to downloading remote audio to the device for offline playback.
|
||||
|
||||
- Note: All saved urls generated from downloaded audio corresponds to a specific remote url. Thus, can be queryed if original remote url is known.
|
||||
|
||||
- Important: Please ensure that you have passed in the background download completion handler in the AppDelegate with `setBackgroundCompletionHandler` to allow for downloading audio while app is in the background.
|
||||
*/
|
||||
public struct Downloader {
|
||||
/**
|
||||
Download audio from a remote url. Will save the audio on the device for playback later.
|
||||
|
||||
Save the saved url of the downloaded audio for future playback or query for the saved url with the same remote url in the future.
|
||||
|
||||
- Note: It's recommended to have a weak reference to a class that uses this function
|
||||
|
||||
- Parameter url: The remote url to download audio from.
|
||||
- Parameter completion: Completion handler that will return once the download is successful and complete.
|
||||
- Parameter savedUrl: The url of where the audio was saved locally on the device. Will receive once download has completed.
|
||||
*/
|
||||
public static func downloadAudio(withRemoteUrl url: URL, completion: @escaping (_ savedUrl: URL) -> ()) {
|
||||
SAPlayer.shared.addUrlToMapping(url: url)
|
||||
AudioDataManager.shared.startDownload(withRemoteURL: url, completion: completion)
|
||||
}
|
||||
|
||||
/**
|
||||
Cancel downloading audio from a specific remote url if actively downloading. If download has not started yet, it will remove from the list of future downloads queued.
|
||||
|
||||
- Parameter url: The remote url corresponding to the active download you want to cancel.
|
||||
*/
|
||||
public static func cancelDownload(withRemoteUrl url: URL) {
|
||||
AudioDataManager.shared.cancelDownload(withRemoteURL: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Delete downloaded audio file from device at url.
|
||||
|
||||
- Note: This will delete any file saved on device at the local url. This, however, is intended to use for audio files.
|
||||
|
||||
- Parameter url: The url of the audio to delete from the device.
|
||||
*/
|
||||
public static func deleteDownloaded(withSavedUrl url: URL) {
|
||||
AudioDataManager.shared.deleteDownload(withLocalURL: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Check if audio at remote url is downloaded on device.
|
||||
|
||||
- Parameter url: The remote url corresponding to the audio file you want to see if downloaded.
|
||||
- Returns: Whether of not file at remote url is downloaded on device.
|
||||
*/
|
||||
public static func isDownloaded(withRemoteUrl url: URL) -> Bool {
|
||||
return AudioDataManager.shared.getPersistedUrl(withRemoteURL: url) != nil
|
||||
}
|
||||
|
||||
/**
|
||||
Get url of audio file downloaded from remote url onto on device if it exists.
|
||||
|
||||
- Parameter url: The remote url corresponding to the audio file you want the device url of.
|
||||
- Returns: Url of audio file on device if it exists.
|
||||
*/
|
||||
public static func getSavedUrl(forRemoteUrl url: URL) -> URL? {
|
||||
return AudioDataManager.shared.getPersistedUrl(withRemoteURL: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Pass along the completion handler from `AppDelegate` to ensure downloading continues while app is in background.
|
||||
|
||||
- Parameter completionHandler: The completion hander from `AppDelegate` to use for app in the background downloads.
|
||||
*/
|
||||
public static func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ()) {
|
||||
AudioDataManager.shared.setBackgroundCompletionHandler(completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// SAPlayerPresenter.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
|
||||
class SAPlayerPresenter {
|
||||
weak var delegate: SAPlayerDelegate?
|
||||
var shouldPlayImmediately = false //for auto-play
|
||||
|
||||
var needle: Needle?
|
||||
var duration: Duration?
|
||||
|
||||
private var key: String?
|
||||
private var isPlaying: SAPlayingStatus = .buffering
|
||||
private var mediaInfo: SALockScreenInfo?
|
||||
|
||||
private var urlKeyMap: [Key: URL] = [:]
|
||||
|
||||
var durationRef:UInt = 0
|
||||
var needleRef:UInt = 0
|
||||
var playingStatusRef:UInt = 0
|
||||
|
||||
init(delegate: SAPlayerDelegate?) {
|
||||
self.delegate = delegate
|
||||
|
||||
delegate?.setLockScreenControls(presenter: self)
|
||||
|
||||
prepareNextEpisodeToPlay()
|
||||
}
|
||||
|
||||
func getUrl(forKey key: Key) -> URL? {
|
||||
return urlKeyMap[key]
|
||||
}
|
||||
|
||||
func addUrlToKeyMap(_ url: URL) {
|
||||
urlKeyMap[url.key] = url
|
||||
}
|
||||
|
||||
func handlePlaySavedAudio(withSavedUrl url: URL) {
|
||||
attachForUpdates(url: url)
|
||||
delegate?.startAudioDownloaded(withSavedUrl: url)
|
||||
}
|
||||
|
||||
func handlePlayStreamedAudio(withRemoteUrl url: URL) {
|
||||
attachForUpdates(url: url)
|
||||
delegate?.startAudioStreamed(withRemoteUrl: url)
|
||||
}
|
||||
|
||||
private func attachForUpdates(url: URL) {
|
||||
AudioClockDirector.shared.detachFromChangesInDuration(withID: durationRef)
|
||||
AudioClockDirector.shared.detachFromChangesInNeedle(withID: needleRef)
|
||||
AudioClockDirector.shared.detachFromChangesInPlayingStatus(withID: playingStatusRef)
|
||||
|
||||
self.key = url.key
|
||||
urlKeyMap[url.key] = url
|
||||
|
||||
durationRef = AudioClockDirector.shared.attachToChangesInDuration(closure: { [weak self] (key, duration) in
|
||||
guard let self = self else { throw DirectorError.closureIsDead }
|
||||
guard key == self.key else {
|
||||
Log.debug("misfire expected key: \(self.key ?? "none") payload key: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
self.delegate?.updateLockscreenPlaybackDuration(duration: duration)
|
||||
self.duration = duration
|
||||
|
||||
if let info = self.mediaInfo {
|
||||
self.delegate?.setLockScreenInfo(withMediaInfo: info, duration: duration)
|
||||
}
|
||||
})
|
||||
|
||||
needleRef = AudioClockDirector.shared.attachToChangesInNeedle(closure: { [weak self] (key, needle) in
|
||||
guard let self = self else { throw DirectorError.closureIsDead }
|
||||
guard key == self.key else {
|
||||
Log.debug("misfire expected key: \(self.key ?? "none") payload key: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
self.needle = needle
|
||||
self.delegate?.updateLockscreenElapsedTime(needle: needle)
|
||||
})
|
||||
|
||||
playingStatusRef = AudioClockDirector.shared.attachToChangesInPlayingStatus(closure: { [weak self] (key, isPlaying) in
|
||||
guard let self = self else { throw DirectorError.closureIsDead }
|
||||
guard key == self.key else {
|
||||
Log.debug("misfire expected key: \(self.key ?? "none") payload key: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
self.isPlaying = isPlaying
|
||||
})
|
||||
}
|
||||
|
||||
@available(iOS 10.0, *)
|
||||
func handleLockscreenInfo(info: SALockScreenInfo) {
|
||||
self.mediaInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
//MARK:- Used by outside world including:
|
||||
// SPP, lock screen, directors
|
||||
extension SAPlayerPresenter {
|
||||
func handlePause() {
|
||||
delegate?.pauseEngine()
|
||||
self.delegate?.updateLockscreenPaused()
|
||||
}
|
||||
|
||||
func handlePlay() {
|
||||
delegate?.playEngine()
|
||||
self.delegate?.updateLockscreenPlaying()
|
||||
}
|
||||
|
||||
func handleTogglePlayingAndPausing() {
|
||||
if isPlaying == .playing {
|
||||
handlePause()
|
||||
} else if isPlaying == .paused {
|
||||
handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
func handleSkipForward() {
|
||||
guard let forward = delegate?.skipForwardSeconds else { return }
|
||||
handleSeek(toNeedle: (needle ?? 0) + forward)
|
||||
}
|
||||
|
||||
func handleSkipBackward() {
|
||||
guard let backward = delegate?.skipForwardSeconds else { return }
|
||||
handleSeek(toNeedle: (needle ?? 0) - backward)
|
||||
}
|
||||
|
||||
func handleSeek(toNeedle needle: Needle) {
|
||||
delegate?.seekEngine(toNeedle: needle)
|
||||
}
|
||||
|
||||
func handleSetSpeed(withMultiple: Double) {
|
||||
delegate?.setSpeedEngine(withMultiple: withMultiple)
|
||||
self.delegate?.updateLockscreenChangePlaybackRate(speed: withMultiple)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
//MARK:- For lock screen
|
||||
extension SAPlayerPresenter {
|
||||
func getIsPlaying() -> Bool {
|
||||
return isPlaying == .playing
|
||||
}
|
||||
}
|
||||
|
||||
//MARK:- AVAudioEngineDelegate
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// SAPlayerUpdateSubscription.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-02-18.
|
||||
// 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 SAPlayer {
|
||||
|
||||
/**
|
||||
Receive updates for changing values from the player, such as the duration, elapsed time of playing audio, download progress, and etc.
|
||||
*/
|
||||
public struct Updates {
|
||||
|
||||
/**
|
||||
Updates to changes in the timestamp/elapsed time of the current initialized audio. Aka, where the scrubber's pointer of the audio should be at.
|
||||
*/
|
||||
public struct ElapsedTime {
|
||||
|
||||
/**
|
||||
Subscribe to updates in elapsed time of the playing audio. Aka, the current timestamp of the 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 time.
|
||||
- Parameter url: The corresponding remote URL for the updated playing time.
|
||||
- Parameter timePosition: The current time within the audio that is playing.
|
||||
- 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 (_ url: URL, _ timePosition: Double) -> ()) -> UInt {
|
||||
return AudioClockDirector.shared.attachToChangesInNeedle(closure: { (key, needle) in
|
||||
guard let url = SAPlayer.shared.getUrl(forKey: key) else { return }
|
||||
closure(url, needle)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
Stop recieving updates of changes in elapsed time of audio.
|
||||
|
||||
- Parameter id: The closure with this id will stop receiving updates.
|
||||
*/
|
||||
public static func unsubscribe(_ id: UInt) {
|
||||
AudioClockDirector.shared.detachFromChangesInNeedle(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
*/
|
||||
public struct Duration {
|
||||
|
||||
/**
|
||||
Subscribe to updates to changes in duration of the current audio initialized.
|
||||
|
||||
- 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 updated duration.
|
||||
- Parameter duration: The duration of the current initialized audio.
|
||||
- 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 (_ url: URL, _ duration: Double) -> ()) -> UInt {
|
||||
return AudioClockDirector.shared.attachToChangesInDuration(closure: { (key, duration) in
|
||||
guard let url = SAPlayer.shared.getUrl(forKey: key) else { return }
|
||||
closure(url, duration)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
Stop recieving updates of changes in duration of the current initialized audio.
|
||||
|
||||
- Parameter id: The closure with this id will stop receiving updates.
|
||||
*/
|
||||
public static func unsubscribe(_ id: UInt) {
|
||||
AudioClockDirector.shared.detachFromChangesInDuration(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Updates to changes in the playing/paused status of the player.
|
||||
*/
|
||||
public struct PlayingStatus {
|
||||
|
||||
/**
|
||||
Subscribe to updates to changes in the playing/paused status 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.
|
||||
- Parameter url: The corresponding remote URL for the updated duration.
|
||||
- Parameter playingStatus: Whether the player is playing audio or paused.
|
||||
- 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 (_ url: URL, _ playingStatus: SAPlayingStatus) -> ()) -> UInt {
|
||||
return AudioClockDirector.shared.attachToChangesInPlayingStatus(closure: { (key, isPlaying) in
|
||||
guard let url = SAPlayer.shared.getUrl(forKey: key) else { return }
|
||||
closure(url, isPlaying)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
Stop recieving updates of changes in the playing/paused status of audio.
|
||||
|
||||
- Parameter id: The closure with this id will stop receiving updates.
|
||||
*/
|
||||
public static func unsubscribe(_ id: UInt) {
|
||||
AudioClockDirector.shared.detachFromChangesInPlayingStatus(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
*/
|
||||
public struct StreamingBuffer {
|
||||
|
||||
/**
|
||||
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: 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 updated streaming progress.
|
||||
- Parameter buffer: Availabity of audio that has been downloaded to play.
|
||||
- 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 (_ url: URL, _ buffer: SAAudioAvailabilityRange) -> ()) -> UInt {
|
||||
return AudioClockDirector.shared.attachToChangesInBufferedRange(closure: { (key, buffer) in
|
||||
guard let url = SAPlayer.shared.getUrl(forKey: key) else { return }
|
||||
closure(url, buffer)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
Stop recieving updates of changes in streaming progress.
|
||||
|
||||
- Parameter id: The closure with this id will stop receiving updates.
|
||||
*/
|
||||
public static func unsubscribe(_ id: UInt) {
|
||||
AudioClockDirector.shared.detachFromChangesInBufferedRange(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Updates to 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.
|
||||
*/
|
||||
public struct AudioDownloading {
|
||||
|
||||
/**
|
||||
Subscribe to updates to changes in the progress of downloading audio. This does not correspond to progress in streaming downloads, look at StreamingBuffer for streaming progress.
|
||||
|
||||
- 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 updated download progress.
|
||||
- Parameter progress: Value from 0.0 to 1.0 indicating progress of download.
|
||||
- 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 (_ url: URL, _ progress: Double) -> ()) -> UInt {
|
||||
return DownloadProgressDirector.shared.attach(closure: { (key, progress) in
|
||||
guard let url = SAPlayer.shared.getUrl(forKey: key) else { return }
|
||||
closure(url, progress)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
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) {
|
||||
DownloadProgressDirector.shared.detach(withID: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Constants.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
typealias Needle = Double
|
||||
typealias Duration = Double
|
||||
typealias Key = String
|
||||
typealias AudioURL = URL
|
||||
typealias IsPlaying = Bool
|
||||
typealias ID = String
|
||||
|
||||
typealias NameFile = String //Should have last path component (.mp3)
|
||||
|
||||
let DEBOUNCING_BUFFER_TIME: Double = 1.0
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Date.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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 Date {
|
||||
/**
|
||||
Finds the 64-bit representation of UTC. rand() uses UTC as a seed, so using the raw UTC should be sufficient for our case.
|
||||
|
||||
- Returns: A 64-bit representation of time.
|
||||
*/
|
||||
static func getUTC64() -> UInt {
|
||||
//"On 32-bit platforms, UInt is the same size as UInt32, and on 64-bit platforms, UInt is the same size as UInt64."
|
||||
|
||||
if #available(iOS 11.0, *) {
|
||||
return UInt(Date().timeIntervalSince1970.bitPattern)
|
||||
} else {
|
||||
let time = Date().timeIntervalSince1970.bitPattern & 0xFFFFFFFF;
|
||||
return UInt(time)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
- Returns: UTC in seconds.
|
||||
*/
|
||||
static func getUTC() -> UTC {
|
||||
return Int(Date().timeIntervalSince1970)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// DirectorThreadSafeClosures.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-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
|
||||
|
||||
enum DirectorError: Error {
|
||||
case closureIsDead
|
||||
}
|
||||
|
||||
/**
|
||||
P for payload
|
||||
*/
|
||||
class DirectorThreadSafeClosures<P> {
|
||||
typealias TypeClosure = (Key, P) throws -> Void
|
||||
private var queue: DispatchQueue = DispatchQueue(label: "SwiftAudioPlayer.thread_safe_map", attributes: .concurrent)
|
||||
private var closures: [UInt: TypeClosure] = [:]
|
||||
private var cache: [Key: P] = [:]
|
||||
|
||||
var count: Int {
|
||||
get {
|
||||
return closures.count
|
||||
}
|
||||
}
|
||||
|
||||
func broadcast(key: Key, payload: P) {
|
||||
queue.sync {
|
||||
self.cache[key] = payload
|
||||
var iterator = self.closures.makeIterator()
|
||||
while let element = iterator.next() {
|
||||
do {
|
||||
try element.value(key, payload)
|
||||
} catch {
|
||||
helperRemove(withKey: element.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//UInt is actually 64-bits on modern devices
|
||||
func attach(closure: @escaping TypeClosure) -> UInt {
|
||||
let id: UInt = Date.getUTC64()
|
||||
|
||||
//The director may not yet have the status yet. We should only call the closure if we have it
|
||||
//Let the caller know the immediate value. If it's dead already then stop
|
||||
for (key, val) in cache {
|
||||
do {
|
||||
try closure(key, val)
|
||||
} catch {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
//Replace what's in the map with the new closure
|
||||
helperInsert(withKey: id, closure: closure)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func detach(id: UInt) {
|
||||
helperRemove(withKey: id)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.closures.removeAll()
|
||||
self.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func helperRemove(withKey key: UInt) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.closures[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func helperInsert(withKey key: UInt, closure: @escaping TypeClosure) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.closures[key] = closure
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// Log.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyrights to ColorLog
|
||||
// https://cocoapods.org/pods/ColorLog
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
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.ERROR
|
||||
|
||||
// Used for OSLog
|
||||
private static let SUBSYSTEM: String = "com.SwiftAudioPlayer"
|
||||
|
||||
/**
|
||||
Used for when you're doing tests. Testing log should be removed before commiting
|
||||
|
||||
How to use: Log.test("this is my message")
|
||||
Output: 13:51:38.487 TEST ❇️❇️❇️❇️ in InputNameViewController.swift:addContainerToVC():77:: this is test
|
||||
|
||||
To change the log level, visit the LogLevel enum
|
||||
|
||||
- Parameter logMessage: The message to show
|
||||
- Parameter classPath: automatically generated based on the class that called this function
|
||||
- Parameter functionName: automatically generated based on the function that called this function
|
||||
- Parameter lineNumber: automatically generated based on the line that called this function
|
||||
*/
|
||||
public static func test(_ logMessage: Any, classPath: String = #file, functionName: String = #function, lineNumber: Int = #line) {
|
||||
let fileName = URLUtil.getNameFromStringPath(classPath)
|
||||
if logLevel.rawValue <= LogLevel.TEST.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "TEST ❇️❇️❇️❇️")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Used when something unexpected happen, such as going out of bounds in an array. Errors are typically guarded for.
|
||||
|
||||
How to use: Log.error("this is error")
|
||||
Output: 13:51:38.487 ERROR 🛑🛑🛑🛑 in InputNameViewController.swift:addContainerToVC():76:: this is error
|
||||
|
||||
To change the log level, visit the LogLevel enum
|
||||
|
||||
- Parameter logMessage: The message to show
|
||||
- Parameter classPath: automatically generated based on the class that called this function
|
||||
- Parameter functionName: automatically generated based on the function that called this function
|
||||
- Parameter lineNumber: automatically generated based on the line that called this function
|
||||
*/
|
||||
public static func error(_ 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: "ERROR 🛑🛑🛑🛑")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Used when something catastrophic just happened. Like app about to crash, app state is inconsistent, or possible data corruption.
|
||||
|
||||
How to use: Log.error("this is error")
|
||||
Output: 13:51:38.487 MONITOR 🔥🔥🔥🔥 in InputNameViewController.swift:addContainerToVC():76:: data in corrupted state!
|
||||
|
||||
To change the log level, visit the LogLevel enum
|
||||
|
||||
- Parameter logMessage: The message to show
|
||||
- Parameter classPath: automatically generated based on the class that called this function
|
||||
- Parameter functionName: automatically generated based on the function that called this function
|
||||
- Parameter lineNumber: automatically generated based on the line that called this function
|
||||
*/
|
||||
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 🔥🔥🔥🔥")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Used when something went wrong, but the app can still function.
|
||||
|
||||
How to use: Log.warn("this is warn")
|
||||
Output: 13:51:38.487 WARN ⚠️⚠️⚠️⚠️ in InputNameViewController.swift:addContainerToVC():75:: this is warn
|
||||
|
||||
To change the log level, visit the LogLevel enum
|
||||
|
||||
- Parameter logMessage: The message to show
|
||||
- Parameter classPath: automatically generated based on the class that called this function
|
||||
- Parameter functionName: automatically generated based on the function that called this function
|
||||
- Parameter lineNumber: automatically generated based on the line that called this function
|
||||
*/
|
||||
public static func warn(_ logMessage: Any, classPath: String = #file, functionName: String = #function, lineNumber: Int = #line) {
|
||||
let fileName = URLUtil.getNameFromStringPath(classPath)
|
||||
if logLevel.rawValue <= LogLevel.WARN.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "WARN ⚠️⚠️⚠️⚠️")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Used when you want to show information like username or question asked.
|
||||
|
||||
How to use: Log.info("this is info")
|
||||
Output: 13:51:38.486 INFO 🖤🖤🖤🖤 in InputNameViewController.swift:addContainerToVC():74:: this is info
|
||||
|
||||
To change the log level, visit the LogLevel enum
|
||||
|
||||
- Parameter logMessage: The message to show
|
||||
- Parameter classPath: automatically generated based on the class that called this function
|
||||
- Parameter functionName: automatically generated based on the function that called this function
|
||||
- Parameter lineNumber: automatically generated based on the line that called this function
|
||||
*/
|
||||
public static func info(_ logMessage: Any, classPath: String = #file, functionName: String = #function, lineNumber: Int = #line) {
|
||||
let fileName = URLUtil.getNameFromStringPath(classPath)
|
||||
if logLevel.rawValue <= LogLevel.INFO.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "INFO 🖤🖤🖤🖤")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Used for when you're rebugging and you want to follow what's happening.
|
||||
|
||||
How to use: Log.debug("this is debug")
|
||||
Output: 13:51:38.485 DEBUG 🐝🐝🐝🐝 in InputNameViewController.swift:addContainerToVC():73:: this is debug
|
||||
|
||||
To change the log level, visit the LogLevel enum
|
||||
|
||||
- Parameter logMessage: The message to show
|
||||
- Parameter classPath: automatically generated based on the class that called this function
|
||||
- Parameter functionName: automatically generated based on the function that called this function
|
||||
- Parameter lineNumber: automatically generated based on the line that called this function
|
||||
*/
|
||||
public static func debug(_ logMessage: Any?..., classPath: String = #file, functionName: String = #function, lineNumber: Int = #line) {
|
||||
let fileName = URLUtil.getNameFromStringPath(classPath)
|
||||
if logLevel.rawValue <= LogLevel.DEBUG.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "DEBUG 🐝🐝🐝🐝")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK:- Helpers for Log class
|
||||
fileprivate struct URLUtil {
|
||||
static func getNameFromStringPath(_ stringPath: String) -> String {
|
||||
//URL sees that "+" is a " "
|
||||
let stringPath = stringPath.replacingOccurrences(of: " ", with: "+")
|
||||
let url = URL(string: stringPath)
|
||||
return url!.lastPathComponent
|
||||
}
|
||||
|
||||
static func getNameFromURL(_ url: URL) -> String {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
fileprivate func timeStamp() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Any? {
|
||||
var toLog: String {
|
||||
var strs:[String] = []
|
||||
for element in self {
|
||||
strs.append("\(element ?? "nil")")
|
||||
}
|
||||
return strs.joined(separator: " |^| ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// URL.swift
|
||||
// Pods-SwiftAudioPlayer_Example
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
var key: String {
|
||||
get {
|
||||
return "audio_\(self.absoluteString.hashed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fileprivate extension String {
|
||||
var hashed: UInt64 {
|
||||
get {
|
||||
var result = UInt64 (8742)
|
||||
let buf = [UInt8](self.utf8)
|
||||
for b in buf {
|
||||
result = 127 * (result & 0x00ffffffffffffff) + UInt64(b)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'SwiftAudioPlayer'
|
||||
s.version = '0.1.0'
|
||||
s.version = '1.3.0'
|
||||
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.
|
||||
@@ -28,10 +28,10 @@ SwiftAudioPlayer is a Swift based audio player that can handle streaming from a
|
||||
s.source = { :git => 'https://github.com/tanhakabir/SwiftAudioPlayer.git', :tag => s.version.to_s }
|
||||
# s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'
|
||||
|
||||
s.ios.deployment_target = '8.0'
|
||||
s.ios.deployment_target = '10.0'
|
||||
|
||||
s.source_files = 'SwiftAudioPlayer/Classes/**/*'
|
||||
s.swift_version = '4.0'
|
||||
s.source_files = 'Source/**/*'
|
||||
s.swift_version = '4.2'
|
||||
|
||||
# s.resource_bundles = {
|
||||
# 'SwiftAudioPlayer' => ['SwiftAudioPlayer/Assets/*.png']
|
||||
|
||||
Reference in New Issue
Block a user