diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index e66b82b..2cf0e06 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -9,34 +9,16 @@ on: branches: - '*' -# jobs: -# iOS: -# name: Test iOS -# runs-on: macOS-latest -# env: -# DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer -# strategy: -# matrix: -# destination: ["OS=latest,name=iPhone 13 Pro"] -# steps: -# - uses: actions/checkout@v2 -# - name: iOS - ${{ matrix.destination }} -# run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "AudioStreaming.xcodeproj" -scheme "AudioStreaming" -destination "${{ matrix.destination }}" clean test | xcpretty - jobs: - build: - name: Swift ${{ matrix.swift }} on ${{ matrix.os }} + iOS: + name: Test iOS + runs-on: macOS-latest + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer strategy: matrix: - os: [macos-latest] - swift: ["5.9"] - runs-on: ${{ matrix.os }} + destination: ["OS=latest,name=iPhone 15 Pro"] steps: - - uses: swift-actions/setup-swift@65540b95f51493d65f5e59e97dcef9629ddf11bf - with: - swift-version: ${{ matrix.swift }} - - uses: actions/checkout@v4 - - name: Build - run: swift build - - name: Run tests - run: swift test + - uses: actions/checkout@v2 + - name: iOS - ${{ matrix.destination }} + run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "AudioStreaming.xcodeproj" -scheme "AudioStreaming" -destination "${{ matrix.destination }}" clean test | xcpretty diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/AudioStreamingTests.xcbaseline/E340D9FA-D19A-49BB-82AA-9D0E236D4288.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/AudioStreamingTests.xcbaseline/E340D9FA-D19A-49BB-82AA-9D0E236D4288.plist new file mode 100644 index 0000000..1f88fa8 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcbaselines/AudioStreamingTests.xcbaseline/E340D9FA-D19A-49BB-82AA-9D0E236D4288.plist @@ -0,0 +1,22 @@ + + + + + classNames + + AtomicTests + + testProtectedValuesAreAccessedSafely() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.029769 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/AudioStreamingTests.xcbaseline/Info.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/AudioStreamingTests.xcbaseline/Info.plist new file mode 100644 index 0000000..af37023 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcbaselines/AudioStreamingTests.xcbaseline/Info.plist @@ -0,0 +1,40 @@ + + + + + runDestinationsByUUID + + E340D9FA-D19A-49BB-82AA-9D0E236D4288 + + localComputer + + busSpeedInMHz + 0 + cpuCount + 1 + cpuKind + Apple M1 Pro + cpuSpeedInMHz + 0 + logicalCPUCoresPerPackage + 10 + modelCode + MacBookPro18,1 + physicalCPUCoresPerPackage + 10 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + arm64 + targetDevice + + modelCode + iPhone16,1 + platformIdentifier + com.apple.platform.iphonesimulator + + + + + diff --git a/AudioExample/.DS_Store b/AudioExample/.DS_Store deleted file mode 100644 index b6eab89..0000000 Binary files a/AudioExample/.DS_Store and /dev/null differ diff --git a/AudioExample/AudioExample.xcodeproj/project.pbxproj b/AudioExample/AudioExample.xcodeproj/project.pbxproj deleted file mode 100644 index 8648cc6..0000000 --- a/AudioExample/AudioExample.xcodeproj/project.pbxproj +++ /dev/null @@ -1,442 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 50; - objects = { - -/* Begin PBXBuildFile section */ - 984808A028C0F549001160E6 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9848089F28C0F549001160E6 /* hipjazz.wav */; }; - 98C82AE22B8CA16A00AED485 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */; }; - B5220836256051830086FB3A /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220835256051830086FB3A /* AudioPlayerService.swift */; }; - B5220948256074910086FB3A /* MulticastDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220947256074910086FB3A /* MulticastDelegate.swift */; }; - B52209502561883E0086FB3A /* EqualizerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B522094F2561883E0086FB3A /* EqualizerViewController.swift */; }; - B5220954256188590086FB3A /* EqualizerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220953256188590086FB3A /* EqualizerViewModel.swift */; }; - B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */; }; - B524D5A12560302100F5A88F /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A02560302100F5A88F /* PlayerViewController.swift */; }; - B524D5A32560303000F5A88F /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A22560303000F5A88F /* PlayerViewModel.swift */; }; - B524D5A52560303D00F5A88F /* PlayerControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */; }; - B524D5A72560305800F5A88F /* PlayerControlsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */; }; - B524D5A9256031DE00F5A88F /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A8256031DE00F5A88F /* AppCoordinator.swift */; }; - B524D5AD25604E4B00F5A88F /* PlaylistItemsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */; }; - B524D5AF25604ED900F5A88F /* AudioContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5AE25604ED900F5A88F /* AudioContent.swift */; }; - B580CB0E2561B912006D7DD8 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B580CB0D2561B912006D7DD8 /* EqualizerService.swift */; }; - B5AEDBD52475274C007D8101 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AEDBD42475274C007D8101 /* AppDelegate.swift */; }; - B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5AEDBDD2475274D007D8101 /* Assets.xcassets */; }; - B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */; }; - B5F883C624780A3D00D277C1 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F883C524780A3C00D277C1 /* AudioStreaming.framework */; }; - B5F883C724780A3D00D277C1 /* AudioStreaming.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B5F883C524780A3C00D277C1 /* AudioStreaming.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - B5F883C824780A3D00D277C1 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - B5F883C724780A3D00D277C1 /* AudioStreaming.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 9848089F28C0F549001160E6 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = ""; }; - 98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = ""; }; - B5220835256051830086FB3A /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; - B5220947256074910086FB3A /* MulticastDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticastDelegate.swift; sourceTree = ""; }; - B522094F2561883E0086FB3A /* EqualizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewController.swift; sourceTree = ""; }; - B5220953256188590086FB3A /* EqualizerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewModel.swift; sourceTree = ""; }; - B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = ""; }; - B524D5A02560302100F5A88F /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = ""; }; - B524D5A22560303000F5A88F /* PlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModel.swift; sourceTree = ""; }; - B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsViewController.swift; sourceTree = ""; }; - B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsViewModel.swift; sourceTree = ""; }; - B524D5A8256031DE00F5A88F /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; - B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistItemsService.swift; sourceTree = ""; }; - B524D5AE25604ED900F5A88F /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = ""; }; - B580CB0D2561B912006D7DD8 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = ""; }; - B5AEDBD12475274C007D8101 /* AudioExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B5AEDBD42475274C007D8101 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B5AEDBDD2475274D007D8101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B5AEDBE02475274D007D8101 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - B5AEDBE22475274D007D8101 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B5F883C524780A3C00D277C1 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - B5AEDBCE2475274C007D8101 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - B5F883C624780A3D00D277C1 /* AudioStreaming.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - B524D59D2560177C00F5A88F /* Resources */ = { - isa = PBXGroup; - children = ( - 98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */, - 9848089F28C0F549001160E6 /* hipjazz.wav */, - B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */, - B5AEDBDD2475274D007D8101 /* Assets.xcassets */, - B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */, - ); - path = Resources; - sourceTree = ""; - }; - B524D5AA25604E2E00F5A88F /* Services */ = { - isa = PBXGroup; - children = ( - B524D5AE25604ED900F5A88F /* AudioContent.swift */, - B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */, - B5220835256051830086FB3A /* AudioPlayerService.swift */, - B5220947256074910086FB3A /* MulticastDelegate.swift */, - B580CB0D2561B912006D7DD8 /* EqualizerService.swift */, - ); - path = Services; - sourceTree = ""; - }; - B524D5AB25604E3500F5A88F /* Controllers */ = { - isa = PBXGroup; - children = ( - B524D5A02560302100F5A88F /* PlayerViewController.swift */, - B524D5A22560303000F5A88F /* PlayerViewModel.swift */, - B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */, - B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */, - B522094F2561883E0086FB3A /* EqualizerViewController.swift */, - B5220953256188590086FB3A /* EqualizerViewModel.swift */, - ); - path = Controllers; - sourceTree = ""; - }; - B5AEDBC82475274C007D8101 = { - isa = PBXGroup; - children = ( - B5AEDBD32475274C007D8101 /* AudioExample */, - B5AEDBD22475274C007D8101 /* Products */, - B5F883C424780A3C00D277C1 /* Frameworks */, - ); - sourceTree = ""; - wrapsLines = 0; - }; - B5AEDBD22475274C007D8101 /* Products */ = { - isa = PBXGroup; - children = ( - B5AEDBD12475274C007D8101 /* AudioExample.app */, - ); - name = Products; - sourceTree = ""; - }; - B5AEDBD32475274C007D8101 /* AudioExample */ = { - isa = PBXGroup; - children = ( - B5AEDBD42475274C007D8101 /* AppDelegate.swift */, - B524D5A8256031DE00F5A88F /* AppCoordinator.swift */, - B524D5AA25604E2E00F5A88F /* Services */, - B524D5AB25604E3500F5A88F /* Controllers */, - B524D59D2560177C00F5A88F /* Resources */, - B5AEDBE22475274D007D8101 /* Info.plist */, - ); - path = AudioExample; - sourceTree = ""; - }; - B5F883C424780A3C00D277C1 /* Frameworks */ = { - isa = PBXGroup; - children = ( - B5F883C524780A3C00D277C1 /* AudioStreaming.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - B5AEDBD02475274C007D8101 /* AudioExample */ = { - isa = PBXNativeTarget; - buildConfigurationList = B5AEDBE52475274D007D8101 /* Build configuration list for PBXNativeTarget "AudioExample" */; - buildPhases = ( - B5AEDBCD2475274C007D8101 /* Sources */, - B5AEDBCE2475274C007D8101 /* Frameworks */, - B5AEDBCF2475274C007D8101 /* Resources */, - B5F883C824780A3D00D277C1 /* Embed Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = AudioExample; - productName = AudioExample; - productReference = B5AEDBD12475274C007D8101 /* AudioExample.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - B5AEDBC92475274C007D8101 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 1140; - LastUpgradeCheck = 1200; - ORGANIZATIONNAME = "Dimitrios Chatzieleftheriou"; - TargetAttributes = { - B5AEDBD02475274C007D8101 = { - CreatedOnToolsVersion = 11.4; - }; - }; - }; - buildConfigurationList = B5AEDBCC2475274C007D8101 /* Build configuration list for PBXProject "AudioExample" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = B5AEDBC82475274C007D8101; - productRefGroup = B5AEDBD22475274C007D8101 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - B5AEDBD02475274C007D8101 /* AudioExample */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - B5AEDBCF2475274C007D8101 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */, - B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */, - B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */, - 98C82AE22B8CA16A00AED485 /* bensound-jazzyfrenchy.m4a in Resources */, - 984808A028C0F549001160E6 /* hipjazz.wav in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - B5AEDBCD2475274C007D8101 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B524D5AF25604ED900F5A88F /* AudioContent.swift in Sources */, - B524D5A9256031DE00F5A88F /* AppCoordinator.swift in Sources */, - B524D5AD25604E4B00F5A88F /* PlaylistItemsService.swift in Sources */, - B524D5A32560303000F5A88F /* PlayerViewModel.swift in Sources */, - B5220836256051830086FB3A /* AudioPlayerService.swift in Sources */, - B5AEDBD52475274C007D8101 /* AppDelegate.swift in Sources */, - B524D5A12560302100F5A88F /* PlayerViewController.swift in Sources */, - B580CB0E2561B912006D7DD8 /* EqualizerService.swift in Sources */, - B5220954256188590086FB3A /* EqualizerViewModel.swift in Sources */, - B5220948256074910086FB3A /* MulticastDelegate.swift in Sources */, - B524D5A52560303D00F5A88F /* PlayerControlsViewController.swift in Sources */, - B524D5A72560305800F5A88F /* PlayerControlsViewModel.swift in Sources */, - B52209502561883E0086FB3A /* EqualizerViewController.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - B5AEDBE02475274D007D8101 /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - B5AEDBE32475274D007D8101 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - B5AEDBE42475274D007D8101 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - B5AEDBE62475274D007D8101 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = AudioExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - B5AEDBE72475274D007D8101 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = AudioExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - B5AEDBCC2475274C007D8101 /* Build configuration list for PBXProject "AudioExample" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B5AEDBE32475274D007D8101 /* Debug */, - B5AEDBE42475274D007D8101 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B5AEDBE52475274D007D8101 /* Build configuration list for PBXNativeTarget "AudioExample" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B5AEDBE62475274D007D8101 /* Debug */, - B5AEDBE72475274D007D8101 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = B5AEDBC92475274C007D8101 /* Project object */; -} diff --git a/AudioExample/AudioExample.xcodeproj/xcuserdata/dimitrisc.xcuserdatad/xcschemes/xcschememanagement.plist b/AudioExample/AudioExample.xcodeproj/xcuserdata/dimitrisc.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index aa6680a..0000000 --- a/AudioExample/AudioExample.xcodeproj/xcuserdata/dimitrisc.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - SchemeUserState - - AudioExample.xcscheme_^#shared#^_ - - orderHint - 0 - - - SuppressBuildableAutocreation - - B5AEDBD02475274C007D8101 - - primary - - - - - diff --git a/AudioExample/AudioExample/AppCoordinator.swift b/AudioExample/AudioExample/AppCoordinator.swift deleted file mode 100644 index 0749f55..0000000 --- a/AudioExample/AudioExample/AppCoordinator.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// AppCoordinator.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import AVFoundation -import UIKit - -final class AppCoordinator { - enum Route { - case equalizer - } - - private var navigationController: UINavigationController? - - private let playerService: AudioPlayerService - private let equaliserService: EqualizerService - - init() { - playerService = AudioPlayerService() - equaliserService = EqualizerService(playerService: playerService) - } - - func start(window: UIWindow) { - window.rootViewController = buildMain() - window.makeKeyAndVisible() - } - - private func buildMain() -> UINavigationController { - let playlistItemsService = PlaylistItemsService(initialItemsProvider: provideInitialPlaylistItems) - let viewModel = PlayerViewModel(playlistItemsService: playlistItemsService, - playerService: playerService, - routeTo: { [weak self] in self?.routeTo($0) }) - let viewController = PlayerViewController(viewModel: viewModel, - controlsProvider: providePlayerControls) - - let navigationController = UINavigationController(rootViewController: viewController) - self.navigationController = navigationController - return navigationController - } - - private func routeTo(_ route: AppCoordinator.Route) { - switch route { - case .equalizer: - showEqualizerControls() - } - } - - private func providePlayerControls() -> UIViewController { - let viewModel = PlayerControlsViewModel(playerService: playerService) - return PlayerControlsViewController(viewModel: viewModel) - } - - private func showEqualizerControls() { - let viewModel = EqualzerViewModel(equalizerService: equaliserService) - let viewController = EqualizerViewController(viewModel: viewModel) - let navigationController = UINavigationController(rootViewController: viewController) - self.navigationController?.present(navigationController, animated: true, completion: nil) - } -} diff --git a/AudioExample/AudioExample/AppDelegate.swift b/AudioExample/AudioExample/AppDelegate.swift deleted file mode 100644 index 929188a..0000000 --- a/AudioExample/AudioExample/AppDelegate.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// AppDelegate.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 20/05/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import UIKit - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? - var appCoordinator: AppCoordinator? - - func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - let window = UIWindow(frame: UIScreen.main.bounds) - let appCoordinator = AppCoordinator() - appCoordinator.start(window: window) - self.window = window - self.appCoordinator = appCoordinator - - return true - } -} diff --git a/AudioExample/AudioExample/Controllers/EqualizerViewController.swift b/AudioExample/AudioExample/Controllers/EqualizerViewController.swift deleted file mode 100644 index c3d8a94..0000000 --- a/AudioExample/AudioExample/Controllers/EqualizerViewController.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// EqualizerViewController.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 15/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import UIKit - -class EqualizerViewController: UIViewController { - private lazy var enableTextLabel = UILabel() - private lazy var enableButton = UISwitch() - - private var eqSlider = [UISlider]() - - private let viewModel: EqualzerViewModel - - init(viewModel: EqualzerViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - title = "Equaliser" - view.backgroundColor = .systemBackground - - navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetEq)) - - enableTextLabel.translatesAutoresizingMaskIntoConstraints = false - enableTextLabel.text = "Enable" - - enableButton.translatesAutoresizingMaskIntoConstraints = false - enableButton.isOn = viewModel.equaliserIsOn - enableButton.onTintColor = .systemTeal - enableButton.addTarget(self, action: #selector(enableEq), for: .valueChanged) - - let enableStackView = UIStackView(arrangedSubviews: [enableTextLabel, enableButton]) - enableStackView.translatesAutoresizingMaskIntoConstraints = false - enableStackView.axis = .horizontal - enableStackView.alignment = .center - enableStackView.spacing = 10 - enableStackView.isLayoutMarginsRelativeArrangement = true - enableStackView.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10) - - let equaliserControls = UIStackView(arrangedSubviews: buildSliders()) - equaliserControls.translatesAutoresizingMaskIntoConstraints = false - equaliserControls.axis = .vertical - equaliserControls.alignment = .fill - equaliserControls.distribution = .fillEqually - - let stackView = UIStackView(arrangedSubviews: [enableStackView, equaliserControls]) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.isLayoutMarginsRelativeArrangement = true - stackView.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10) - - view.addSubview(stackView) - - NSLayoutConstraint.activate( - [ - enableStackView.heightAnchor.constraint(equalToConstant: 60), - stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.8), - ] - ) - } - - @objc func enableEq() { - viewModel.enableEq(enableButton.isOn) - } - - @objc func resetEq() { - viewModel.resetEq { value in - eqSlider.forEach { $0.setValue(value, animated: true) } - } - } - - private func buildSliders() -> [UIView] { - var sliders = [UIView]() - for index in 0 ..< viewModel.numberOfBands() { - guard let item = viewModel.band(at: index) else { continue } - let slider = buildSlider(item: item, index: index) - sliders.append(slider) - } - return sliders - } - - @objc private func valueChanged(_ slider: UISlider) { - viewModel.update(gain: slider.value, for: slider.tag) - } - - private func buildSlider(item: EQBand, index: Int) -> UIView { - let freqLabel = UILabel() - freqLabel.translatesAutoresizingMaskIntoConstraints = false - freqLabel.text = item.frequency - freqLabel.textAlignment = .right - freqLabel.widthAnchor.constraint(equalToConstant: 40).isActive = true - - let slider = UISlider() - slider.translatesAutoresizingMaskIntoConstraints = false - slider.tag = index // cheating here - slider.minimumValue = item.min - slider.maximumValue = item.max - slider.value = item.value - slider.isContinuous = true - slider.addTarget(self, action: #selector(valueChanged(_:)), for: .valueChanged) - eqSlider.append(slider) - - let minLabel = UILabel() - minLabel.translatesAutoresizingMaskIntoConstraints = false - minLabel.text = "\(item.min)db" - - let centerLabel = UILabel() - centerLabel.translatesAutoresizingMaskIntoConstraints = false - centerLabel.text = "0db" - centerLabel.textAlignment = .center - - let maxLabel = UILabel() - maxLabel.translatesAutoresizingMaskIntoConstraints = false - maxLabel.text = "\(item.max)db" - maxLabel.textAlignment = .right - - let dbStackView = UIStackView(arrangedSubviews: [minLabel, centerLabel, maxLabel]) - dbStackView.translatesAutoresizingMaskIntoConstraints = false - dbStackView.axis = .horizontal - dbStackView.distribution = .fillEqually - - let stackViewSlider = UIStackView(arrangedSubviews: [slider, dbStackView]) - stackViewSlider.spacing = 5 - stackViewSlider.translatesAutoresizingMaskIntoConstraints = false - stackViewSlider.axis = .vertical - stackViewSlider.setContentHuggingPriority(.fittingSizeLevel, for: .horizontal) - stackViewSlider.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal) - - let stackView = UIStackView(arrangedSubviews: [freqLabel, stackViewSlider]) - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .horizontal - stackView.distribution = .fillProportionally - stackView.alignment = .fill - stackView.isLayoutMarginsRelativeArrangement = true - stackView.directionalLayoutMargins = .init(top: 0, leading: 10, bottom: 0, trailing: 10) - - return stackView - } -} diff --git a/AudioExample/AudioExample/Controllers/EqualizerViewModel.swift b/AudioExample/AudioExample/Controllers/EqualizerViewModel.swift deleted file mode 100644 index 7e27b27..0000000 --- a/AudioExample/AudioExample/Controllers/EqualizerViewModel.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// EqualizerViewModel.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 15/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import AVFoundation - -struct EQBand { - let frequency: String - let min: Float - let max: Float - let value: Float -} - -final class EqualzerViewModel { - private var bands: [EQBand] = [] - - private let equalizerService: EqualizerService - - var equaliserIsOn: Bool { - equalizerService.isActivated - } - - init(equalizerService: EqualizerService) { - self.equalizerService = equalizerService - - bands = equalizerService.bands.map { item in - var measurement = item.frequency - var frequency = String(Int(measurement)) - if item.frequency >= 1000 { - measurement = item.frequency / 1000 - frequency = "\(String(Int(measurement)))K" - } - return EQBand(frequency: frequency, min: -12, max: 12, value: item.gain) - } - } - - func enableEq(_ enable: Bool) { - if enable { - equalizerService.activate() - } else { - equalizerService.deactivate() - } - } - - func resetEq(updateSliders: (_ value: Float) -> Void) { - equalizerService.reset() - updateSliders(0) - } - - func update(gain: Float, for index: Int) { - equalizerService.update(gain: gain, for: index) - } - - func numberOfBands() -> Int { - equalizerService.bands.count - } - - func band(at index: Int) -> EQBand? { - guard index < numberOfBands() else { return nil } - return bands[index] - } -} diff --git a/AudioExample/AudioExample/Controllers/PlayerControlsViewController.swift b/AudioExample/AudioExample/Controllers/PlayerControlsViewController.swift deleted file mode 100644 index a31c582..0000000 --- a/AudioExample/AudioExample/Controllers/PlayerControlsViewController.swift +++ /dev/null @@ -1,213 +0,0 @@ -// -// PlayerControlsViewController.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import UIKit - -class PlayerControlsViewController: UIViewController { - private lazy var resumeButton = UIButton() - private lazy var stopButton = UIButton(type: .custom) - private lazy var muteButton = UIButton() - - private lazy var slider = UISlider() - private lazy var elapsedPlayTimeLabel = UILabel() - private lazy var remainingPlayTimeLabel = UILabel() - - private lazy var rateSlider = UISlider() - private lazy var rateSliderValueLabel = UILabel() - - private lazy var playerStatus = UILabel() - - private let viewModel: PlayerControlsViewModel - - init(viewModel: PlayerControlsViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - setupUI() - setupBinding() - } - - private func setupUI() { - muteButton.translatesAutoresizingMaskIntoConstraints = false - muteButton.setTitle("Mute", for: .normal) - muteButton.setTitleColor(.label, for: .normal) - muteButton.setTitleColor(.secondaryLabel, for: .highlighted) - muteButton.setTitleColor(.tertiaryLabel, for: .disabled) - muteButton.accessibilityIdentifier = "muteButton" - muteButton.addTarget(self, action: #selector(toggleMute), for: .touchUpInside) - - resumeButton.translatesAutoresizingMaskIntoConstraints = false - resumeButton.setTitle("Pause", for: .normal) - resumeButton.accessibilityIdentifier = "resumeButton" - resumeButton.setTitleColor(.label, for: .normal) - resumeButton.setTitleColor(.secondaryLabel, for: .highlighted) - resumeButton.setTitleColor(.tertiaryLabel, for: .disabled) - resumeButton.addTarget(self, action: #selector(pauseResume), for: .touchUpInside) - - stopButton.translatesAutoresizingMaskIntoConstraints = false - stopButton.setTitle("Stop", for: .normal) - stopButton.setTitleColor(.label, for: .normal) - stopButton.setTitleColor(.secondaryLabel, for: .highlighted) - stopButton.setTitleColor(.tertiaryLabel, for: .disabled) - stopButton.accessibilityIdentifier = "stopButton" - stopButton.addTarget(self, action: #selector(stop), for: .touchUpInside) - - let controlsStackView = UIStackView(arrangedSubviews: [resumeButton, stopButton, muteButton]) - controlsStackView.translatesAutoresizingMaskIntoConstraints = false - controlsStackView.axis = .horizontal - controlsStackView.distribution = .fillEqually - controlsStackView.alignment = .center - controlsStackView.accessibilityIdentifier = "controlsStackView" - - slider.translatesAutoresizingMaskIntoConstraints = false - slider.accessibilityIdentifier = "slider" - slider.tintColor = .systemGray2 - slider.thumbTintColor = .systemGray - slider.isContinuous = true - slider.semanticContentAttribute = .playback - slider.addTarget(self, action: #selector(sliderTouchedDown), for: .touchDown) - slider.addTarget(self, action: #selector(sliderTouchedUp), for: [.touchUpInside, .touchUpOutside]) - slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged) - - elapsedPlayTimeLabel.text = "--:--" - elapsedPlayTimeLabel.accessibilityIdentifier = "elapsedPlayTimeLabel" - elapsedPlayTimeLabel.translatesAutoresizingMaskIntoConstraints = false - elapsedPlayTimeLabel.font = UIFont.preferredFont(forTextStyle: .footnote) - elapsedPlayTimeLabel.textAlignment = .left - remainingPlayTimeLabel.text = "--:--" - remainingPlayTimeLabel.accessibilityIdentifier = "remainingPlayTimeLabel" - remainingPlayTimeLabel.translatesAutoresizingMaskIntoConstraints = false - remainingPlayTimeLabel.font = UIFont.preferredFont(forTextStyle: .footnote) - remainingPlayTimeLabel.textAlignment = .right - - let playbackTimeLabelsStack = UIStackView(arrangedSubviews: [elapsedPlayTimeLabel, remainingPlayTimeLabel]) - playbackTimeLabelsStack.translatesAutoresizingMaskIntoConstraints = false - playbackTimeLabelsStack.axis = .horizontal - playbackTimeLabelsStack.distribution = .fillEqually - playbackTimeLabelsStack.accessibilityIdentifier = "playbackTimeLabelsStack" - - playerStatus.text = "" - playerStatus.translatesAutoresizingMaskIntoConstraints = false - playerStatus.numberOfLines = 0 - playerStatus.accessibilityIdentifier = "playerStatus-label" - - let sliderLabel = UILabel() - sliderLabel.translatesAutoresizingMaskIntoConstraints = false - sliderLabel.text = "Rate: " - - rateSliderValueLabel.translatesAutoresizingMaskIntoConstraints = false - rateSliderValueLabel.text = viewModel.currentRateTitle - - rateSlider.translatesAutoresizingMaskIntoConstraints = false - rateSlider.minimumValue = viewModel.rateMinValue - rateSlider.maximumValue = viewModel.rateMaxValue - rateSlider.value = viewModel.rateMinValue - rateSlider.addTarget(self, action: #selector(rateValueChanged), for: .valueChanged) - - let sliderWarningLabel = UILabel() - sliderWarningLabel.translatesAutoresizingMaskIntoConstraints = false - sliderWarningLabel.text = "Adjusting rate in live broadcast is not recommended" - sliderWarningLabel.numberOfLines = 2 - sliderWarningLabel.textColor = .systemRed - - let rateSliderStackView = UIStackView(arrangedSubviews: [sliderLabel, rateSlider, rateSliderValueLabel]) - rateSliderStackView.spacing = 10 - rateSliderStackView.axis = .horizontal - - let controlsAndSliderStack = UIStackView(arrangedSubviews: [controlsStackView, - slider, - playbackTimeLabelsStack, - playerStatus, - rateSliderStackView, - sliderWarningLabel]) - controlsAndSliderStack.translatesAutoresizingMaskIntoConstraints = false - controlsAndSliderStack.spacing = 10 - controlsAndSliderStack.setCustomSpacing(15, after: playbackTimeLabelsStack) - controlsAndSliderStack.axis = .vertical - controlsAndSliderStack.distribution = .fill - controlsAndSliderStack.alignment = .fill - controlsAndSliderStack.isLayoutMarginsRelativeArrangement = true - controlsAndSliderStack.layoutMargins = .init(top: 15, left: 10, bottom: 0, right: 10) - controlsAndSliderStack.accessibilityIdentifier = "controlsAndSliderStack" - - view.addSubview(controlsAndSliderStack) - view.accessibilityIdentifier = "controller-view" - - NSLayoutConstraint.activate([ - controlsAndSliderStack.topAnchor.constraint(equalTo: view.topAnchor), - controlsAndSliderStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - controlsAndSliderStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - } - - private func setupBinding() { - viewModel.updateContent = { [unowned self] effect in - switch effect { - case let .updateMuteButton(title): - self.muteButton.setTitle(title, for: .normal) - case let .updatePauseResumeButton(title): - self.resumeButton.setTitle(title, for: .normal) - case let .updateSliderMinMaxValue(min, max): - self.slider.minimumValue = min - self.slider.maximumValue = max - case let .updateSliderValue(value): - self.slider.value = value - case let .updateMetadata(title): - self.playerStatus.text = title - } - } - - viewModel.updateProgressAndDurationTitles = { [elapsedPlayTimeLabel, remainingPlayTimeLabel] progress, duration in - elapsedPlayTimeLabel.text = progress - remainingPlayTimeLabel.text = duration - } - } - - @objc private func rateValueChanged() { - viewModel.update(rate: rateSlider.value) { [rateSlider] value in - rateSlider.value = value - } - rateSliderValueLabel.text = viewModel.currentRateTitle - } - - @objc private func toggleMute() { - viewModel.toggleMute() - } - - @objc private func pauseResume() { - viewModel.togglePauseResume() - } - - @objc private func stop() { - viewModel.stop() - } - - @objc - func sliderTouchedDown() { - viewModel.seek(action: .started) - } - - @objc - func sliderTouchedUp() { - viewModel.seek(action: .ended) - } - - @objc - func sliderValueChanged() { - viewModel.seek(action: .updateSeek(time: slider.value)) - } -} diff --git a/AudioExample/AudioExample/Controllers/PlayerControlsViewModel.swift b/AudioExample/AudioExample/Controllers/PlayerControlsViewModel.swift deleted file mode 100644 index 38b20df..0000000 --- a/AudioExample/AudioExample/Controllers/PlayerControlsViewModel.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// PlayerControlsViewModel.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import AudioStreaming -import Foundation -import UIKit - -enum SeekAction: Equatable { - case started - case updateSeek(time: Float) - case ended -} - -enum ControlsEffects { - case updateMuteButton(String) - case updatePauseResumeButton(String) - case updateSliderMinMaxValue(min: Float, max: Float) - case updateSliderValue(value: Float) - case updateMetadata(String) -} - -final class PlayerControlsViewModel { - var updateContent: ((ControlsEffects) -> Void)? - var updateProgressAndDurationTitles: ((String, String) -> Void)? - - private let playerService: AudioPlayerService - - private var displayLink: CADisplayLink? - - private var seekTime: Float = 0 - private var isScrubbing: Bool = false - - let rateMinValue: Float = 1.0 - let rateMaxValue: Float = 3.0 - - var currentRateTitle: String { - String(format: "%.1fx", playerService.rate) - } - - init(playerService: AudioPlayerService) { - self.playerService = playerService - self.playerService.delegate.add(delegate: self) - } - - func stop() { - playerService.stop() - stopDisplayLink(resetLabels: true) - updateContent?(.updatePauseResumeButton("Pause")) - } - - func togglePauseResume() { - playerService.toggle() - let isPaused = playerService.state == .paused - updateContent?(.updatePauseResumeButton(isPaused ? "Resume" : "Pause")) - } - - func toggleMute() { - playerService.toggleMute() - let isMuted = playerService.isMuted - updateContent?(.updateMuteButton(isMuted ? "Unmute" : "Mute")) - } - - func seek(action: SeekAction) { - switch action { - case .started: - isScrubbing = true - seekTime = 0 - case let .updateSeek(time): - seekTime = time - case .ended: - isScrubbing = false - if playerService.duration > 0 { - playerService.seek(at: seekTime) - } - } - } - - func update(rate: Float, updater: (Float) -> Void) { - let rate = round(rate / 0.5) * 0.5 - playerService.update(rate: rate) - updater(rate) - } - - private func startDisplayLink() { - displayLink?.invalidate() - displayLink = nil - displayLink = UIScreen.main.displayLink(withTarget: self, selector: #selector(tick)) - displayLink?.preferredFramesPerSecond = 6 - displayLink?.add(to: .current, forMode: .common) - } - - private func stopDisplayLink(resetLabels: Bool) { - displayLink?.invalidate() - displayLink = nil - if resetLabels { - resetLabelsAndSlider() - } - } - - @objc private func tick() { - let duration = playerService.duration - let progress = playerService.progress - if duration > 0 { - let elapsed = Int(progress) - let remaining = Int(duration - progress) - - updateContent?(.updateSliderMinMaxValue(min: 0.0, max: Float(duration))) - if !isScrubbing { - updateContent?(.updateSliderValue(value: Float(progress))) - } - - updateProgressAndDurationTitles?(timeFrom(seconds: elapsed), timeFrom(seconds: remaining)) - } else { - let elapsed = Int(progress) - updateProgressAndDurationTitles?("Live broadcast", timeFrom(seconds: elapsed)) - } - } - - private func resetLabelsAndSlider() { - updateProgressAndDurationTitles?("--:--", "--:--") - updateContent?(.updateSliderMinMaxValue(min: 0, max: 0)) - updateContent?(.updateSliderValue(value: 0)) - } - - private func timeFrom(seconds: Int) -> String { - let correctSeconds = seconds % 60 - let minutes = (seconds / 60) % 60 - let hours = seconds / 3600 - - if hours > 0 { - return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds) - } - return String(format: "%02d:%02d", minutes, correctSeconds) - } -} - -extension PlayerControlsViewModel: AudioPlayerServiceDelegate { - func didStopPlaying() { - stopDisplayLink(resetLabels: true) - updateContent?(.updateMetadata("")) - } - - func statusChanged(status _: AudioPlayerState) {} - - func didStartPlaying() { - startDisplayLink() - resetLabelsAndSlider() - updateContent?(.updateMetadata("")) - } - - func errorOccurred(error _: AudioPlayerError) {} - - func metadataReceived(metadata: [String: String]) { - guard !metadata.isEmpty else { return } - if let title = metadata["StreamTitle"] { - updateContent?(.updateMetadata("Now Playing: \(title)")) - } else { - updateContent?(.updateMetadata("")) - } - } -} diff --git a/AudioExample/AudioExample/Controllers/PlayerViewController.swift b/AudioExample/AudioExample/Controllers/PlayerViewController.swift deleted file mode 100644 index ad72596..0000000 --- a/AudioExample/AudioExample/Controllers/PlayerViewController.swift +++ /dev/null @@ -1,167 +0,0 @@ -// -// PlayerViewController.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import UIKit - -class PlayerViewController: UIViewController { - private lazy var tableView = UITableView() - - private let viewModel: PlayerViewModel - private var controlsProvider: () -> UIViewController - private var playerControlsController: UIViewController? - - init(viewModel: PlayerViewModel, controlsProvider: @escaping () -> UIViewController) { - self.viewModel = viewModel - self.controlsProvider = controlsProvider - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - - viewModel.reloadContent = { [weak self] action in - switch action { - case .all: - self?.tableView.reloadData() - case let .item(indexPath): - self?.tableView.reloadRows(at: [indexPath], with: .automatic) - } - } - } - - private func setupUI() { - title = "Player" - view.backgroundColor = .systemBackground - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, - target: self, - action: #selector(addNowPlaylistItem)) - - navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"), - style: .plain, - target: self, - action: #selector(showEqualizer)) - - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.delegate = self - tableView.dataSource = self - tableView.register(PlaylistTableViewCell.self, forCellReuseIdentifier: "PlaylistCell") - - let controlsController = controlsProvider() - playerControlsController = controlsController - - let stackView = UIStackView() - stackView.axis = .vertical - stackView.alignment = .fill - stackView.distribution = .fillProportionally - stackView.translatesAutoresizingMaskIntoConstraints = false - - stackView.addArrangedSubview(tableView) - - addChild(controlsController) - controlsController.view.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(controlsController.view) - controlsController.didMove(toParent: self) - - view.addSubview(stackView) - - NSLayoutConstraint.activate( - [ - controlsController.view.widthAnchor.constraint(equalTo: view.widthAnchor), - stackView.topAnchor.constraint(equalTo: view.topAnchor), - stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ] - ) - } - - @objc private func showEqualizer() { - viewModel.showEqualizer() - } - - @objc private func addNowPlaylistItem() { - let controller = UIAlertController(title: "Add new item", message: "", preferredStyle: .alert) - controller.addTextField { textField in - textField.placeholder = "Insert url here" - } - let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] _ in - if let textfield = controller.textFields?.first, - let text = textfield.text - { - viewModel.add(urlString: text) - } - } - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - - controller.addAction(saveAction) - controller.addAction(cancelAction) - present(controller, animated: true, completion: nil) - } -} - -extension PlayerViewController: UITableViewDataSource { - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - viewModel.itemsCount - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "PlaylistCell", for: indexPath) - guard let item = viewModel.item(at: indexPath) else { - return cell - } - cell.textLabel?.text = item.name - let queuedItem = item.queues ? "Queue item" : nil - cell.detailTextLabel?.text = queuedItem ?? item.subtitle - update(status: item.status, of: cell) - return cell - } - - private func update(status: PlaylistItem.Status, of cell: UITableViewCell) { - switch status { - case .buffering: - let activity = UIActivityIndicatorView(style: .medium) - activity.startAnimating() - cell.accessoryView = activity - case .playing: - cell.accessoryView = UIImageView(image: UIImage(systemName: "play.fill")) - case .paused: - cell.accessoryView = UIImageView(image: UIImage(systemName: "pause.fill")) - case .stopped: - cell.accessoryView = nil - case .error: - cell.accessoryView = UIImageView(image: UIImage(systemName: "exclamationmark.octagon")) - cell.accessoryView?.tintColor = .red - } - guard status != .error else { return } - cell.accessoryView?.tintColor = .systemTeal - } -} - -extension PlayerViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - viewModel.playItem(at: indexPath) - } -} - -final class PlaylistTableViewCell: UITableViewCell { - override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/AudioExample/AudioExample/Controllers/PlayerViewModel.swift b/AudioExample/AudioExample/Controllers/PlayerViewModel.swift deleted file mode 100644 index b2b9c49..0000000 --- a/AudioExample/AudioExample/Controllers/PlayerViewModel.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// PlayerViewModel.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import AudioStreaming -import Foundation - -enum ReloadAction { - case all - case item(IndexPath) -} - -final class PlayerViewModel { - private let playerService: AudioPlayerService - private let playlistItemsService: PlaylistItemsService - - private let routeTo: (AppCoordinator.Route) -> Void - private var currentPlayingItemIndex: Int? - - var reloadContent: ((ReloadAction) -> Void)? - - init(playlistItemsService: PlaylistItemsService, - playerService: AudioPlayerService, - routeTo: @escaping (AppCoordinator.Route) -> Void) - { - self.playlistItemsService = playlistItemsService - self.playerService = playerService - self.routeTo = routeTo - self.playerService.delegate.add(delegate: self) - } - - func showEqualizer() { - routeTo(.equalizer) - } - - var itemsCount: Int { - playlistItemsService.itemsCount - } - - func add(urlString: String) { - let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let result = detector.firstMatch(in: urlString, options: [], range: NSRange(location: 0, length: urlString.utf16.count)) - guard let url = URL(string: urlString), result != nil else { - print("malformed url error") - return - } - playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, subtitle: nil, status: .stopped, queues: false)) - reloadContent?(.all) - } - - func item(at indexPath: IndexPath) -> PlaylistItem? { - playlistItemsService.item(at: indexPath.row) - } - - func playItem(at indexPath: IndexPath) { - guard let item = item(at: indexPath) else { return } - if item.queues { - playerService.queue(url: item.url) - if currentPlayingItemIndex == nil { - currentPlayingItemIndex = indexPath.row - } - } else { - if let index = currentPlayingItemIndex { - playlistItemsService.setStatus(for: index, status: .stopped) - reloadContent?(.item(IndexPath(row: index, section: 0))) - currentPlayingItemIndex = nil - } - playerService.play(url: item.url) - currentPlayingItemIndex = indexPath.row - } - } -} - -extension PlayerViewModel: AudioPlayerServiceDelegate { - func statusChanged(status: AudioPlayerState) { - guard let item = currentPlayingItemIndex else { return } - - switch status { - case .bufferring: - playlistItemsService.setStatus(for: item, status: .buffering) - reloadContent?(.item(IndexPath(item: item, section: 0))) - case .playing: - playlistItemsService.setStatus(for: item, status: .playing) - reloadContent?(.item(IndexPath(item: item, section: 0))) - case .paused: - playlistItemsService.setStatus(for: item, status: .paused) - reloadContent?(.item(IndexPath(item: item, section: 0))) - case .stopped: - playlistItemsService.setStatus(for: item, status: .stopped) - reloadContent?(.item(IndexPath(item: item, section: 0))) - case .error: - playlistItemsService.setStatus(for: item, status: .error) - reloadContent?(.item(IndexPath(item: item, section: 0))) - default: - playlistItemsService.setStatus(for: item, status: .stopped) - reloadContent?(.all) - } - } - - func errorOccurred(error _: AudioPlayerError) { - currentPlayingItemIndex = nil - } - - func metadataReceived(metadata _: [String: String]) {} - func didStopPlaying() {} - - func didStartPlaying() {} -} diff --git a/AudioExample/AudioExample/Info.plist b/AudioExample/AudioExample/Info.plist deleted file mode 100644 index 5d757ed..0000000 --- a/AudioExample/AudioExample/Info.plist +++ /dev/null @@ -1,52 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UIRequiresFullScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/AudioExample/AudioExample/Resources/.DS_Store b/AudioExample/AudioExample/Resources/.DS_Store deleted file mode 100644 index d6eb710..0000000 Binary files a/AudioExample/AudioExample/Resources/.DS_Store and /dev/null differ diff --git a/AudioExample/AudioExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/AudioExample/AudioExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 9221b9b..0000000 --- a/AudioExample/AudioExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/AudioExample/AudioExample/Resources/Base.lproj/LaunchScreen.storyboard b/AudioExample/AudioExample/Resources/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/AudioExample/AudioExample/Resources/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/AudioExample/AudioExample/Services/MulticastDelegate.swift b/AudioExample/AudioExample/Services/MulticastDelegate.swift deleted file mode 100644 index 4b928c5..0000000 --- a/AudioExample/AudioExample/Services/MulticastDelegate.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// MulticastDelegate.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import Foundation - -class MulticastDelegate { - private let delegates = NSHashTable.weakObjects() - - func add(delegate: Delegate) { - delegates.add(delegate as AnyObject) - } - - func remove(delegate: Delegate) { - for oneDelegate in delegates.allObjects.reversed() { - if oneDelegate === delegate as AnyObject { - delegates.remove(oneDelegate) - } - } - } - - func invoke(invocation: (Delegate) -> Void) { - for delegate in delegates.allObjects.reversed() { - invocation(delegate as! Delegate) - } - } -} diff --git a/AudioExample/AudioExample/Services/NowPlayingCenter.swift b/AudioExample/AudioExample/Services/NowPlayingCenter.swift deleted file mode 100644 index 4ea2a34..0000000 --- a/AudioExample/AudioExample/Services/NowPlayingCenter.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// NowPlayingCenter.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 15/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import MediaPlayer - -final class NowPlayingCenter { - private let infoCenter: MPNowPlayingInfoCenter - - init(infoCenter: MPNowPlayingInfoCenter = .default()) { - self.infoCenter = infoCenter - } - - func change(item: PlaylistItem, isLiveStream: Bool) { - var nowPlayingInfo = infoCenter.nowPlayingInfo ?? [String: Any]() - - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.audio.rawValue - nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = isLiveStream - nowPlayingInfo[MPMediaItemPropertyArtist] = item.name - - infoCenter.nowPlayingInfo = nowPlayingInfo - } - - func update(with metadata: [String: String], with item: PlaylistItem) { - var nowPlayingInfo = infoCenter.nowPlayingInfo ?? [String: Any]() - - nowPlayingInfo[MPMediaItemPropertyTitle] = metadata["StreamTitle"] - nowPlayingInfo[MPMediaItemPropertyArtist] = item.name - - infoCenter.nowPlayingInfo = nowPlayingInfo - } -} diff --git a/AudioExample/AudioExample/Services/PlaylistItemsService.swift b/AudioExample/AudioExample/Services/PlaylistItemsService.swift deleted file mode 100644 index 555c359..0000000 --- a/AudioExample/AudioExample/Services/PlaylistItemsService.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// PlaylistItemsService.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. -// - -import Foundation - -struct PlaylistItem: Equatable { - enum Status: Equatable { - case playing - case paused - case buffering - case stopped - case error - } - - let url: URL - let name: String - let subtitle: String? - let status: Status - let queues: Bool - - init(content: AudioContent, queues: Bool) { - name = content.title - subtitle = content.subtitle - url = content.streamUrl - status = .stopped - self.queues = queues - } - - init(url: URL, name: String, subtitle: String?, status: Status, queues: Bool) { - self.url = url - self.name = name - self.subtitle = subtitle - self.status = status - self.queues = queues - } -} - -final class PlaylistItemsService { - private var items: [PlaylistItem] = [] - - var itemsCount: Int { - items.count - } - - let protectedItemCount: Int - - init(initialItemsProvider: () -> [PlaylistItem]) { - items = initialItemsProvider() - protectedItemCount = items.count - } - - func item(at index: Int) -> PlaylistItem? { - guard index < items.count else { return nil } - return items[index] - } - - func index(for item: PlaylistItem) -> Int? { - items.firstIndex(of: item) - } - - func add(item: PlaylistItem) { - items.append(item) - } - - func remove(item: PlaylistItem) { - if let index = items.firstIndex(of: item) { - items.remove(at: index) - } - } - - func setStatus(for index: Int, status: PlaylistItem.Status) { - guard let item = item(at: index) else { - return - } - items[index] = PlaylistItem( - url: item.url, - name: item.name, - subtitle: item.subtitle, - status: status, - queues: item.queues - ) - } -} - -func provideInitialPlaylistItems() -> [PlaylistItem] { - let allCases = AudioContent.allCases - let casesForQueueing: [AudioContent] = [.piano, .local, .khruangbin] - let allItems = allCases.map { PlaylistItem(content: $0, queues: false) } - let casesForQueuingItems = casesForQueueing.map { PlaylistItem(content: $0, queues: true) } - return allItems + casesForQueuingItems -} diff --git a/AudioPlayer/AudioPlayer.xcodeproj/project.pbxproj b/AudioPlayer/AudioPlayer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..08b7942 --- /dev/null +++ b/AudioPlayer/AudioPlayer.xcodeproj/project.pbxproj @@ -0,0 +1,503 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 9806E8182BC5D12500757370 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8172BC5D12500757370 /* App.swift */; }; + 9806E81A2BC5D12500757370 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8192BC5D12500757370 /* ContentView.swift */; }; + 9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81B2BC5D12700757370 /* Assets.xcassets */; }; + 9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81E2BC5D12700757370 /* Preview Assets.xcassets */; }; + 9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8252BC5D2A900757370 /* Sidebar.swift */; }; + 9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8292BC68F8700757370 /* AudioPlayerView.swift */; }; + 9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8302BC6927D00757370 /* AudioPlayerModel.swift */; }; + 9816A8A52BC7D8A200AD1299 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */; }; + 9816A8A62BC7D8A200AD1299 /* AudioStreaming.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */; }; + 9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8AB2BC820DF00AD1299 /* AudioContent.swift */; }; + 9816A8B12BC8330C00AD1299 /* bensound-jazzyfrenchy.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */; }; + 9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */; }; + 9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */; }; + 9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */; }; + 984DE9552BDAE59C004B427A /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9542BDAE59C004B427A /* Notifier.swift */; }; + 984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */; }; + 98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */; }; + 98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */; }; + 98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */; }; + 98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */; }; + 98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E6119B2BC72C0E0036BC47 /* DetailView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9816A8A72BC7D8A200AD1299 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9816A8A62BC7D8A200AD1299 /* AudioStreaming.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 9806E8142BC5D12500757370 /* AudioPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9806E8172BC5D12500757370 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 9806E8192BC5D12500757370 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 9806E81B2BC5D12700757370 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 9806E81E2BC5D12700757370 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 9806E8252BC5D2A900757370 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; + 9806E8292BC68F8700757370 /* AudioPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerView.swift; sourceTree = ""; }; + 9806E8302BC6927D00757370 /* AudioPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerModel.swift; sourceTree = ""; }; + 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = ""; }; + 9816A8AB2BC820DF00AD1299 /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = ""; }; + 9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = ""; }; + 9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = ""; }; + 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = ""; }; + 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; + 984DE9542BDAE59C004B427A /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = ""; }; + 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerControlsView.swift; sourceTree = ""; }; + 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = ""; }; + 98BFB41B2BCAAD8A00E812C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerView.swift; sourceTree = ""; }; + 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = ""; }; + 98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddNewAudioURLView.swift; sourceTree = ""; }; + 98E6119B2BC72C0E0036BC47 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9806E8112BC5D12500757370 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9816A8A52BC7D8A200AD1299 /* AudioStreaming.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9806E80B2BC5D12500757370 = { + isa = PBXGroup; + children = ( + 9806E8162BC5D12500757370 /* AudioPlayer */, + 9806E8152BC5D12500757370 /* Products */, + 9816A8A32BC7D8A200AD1299 /* Frameworks */, + ); + sourceTree = ""; + }; + 9806E8152BC5D12500757370 /* Products */ = { + isa = PBXGroup; + children = ( + 9806E8142BC5D12500757370 /* AudioPlayer.app */, + ); + name = Products; + sourceTree = ""; + }; + 9806E8162BC5D12500757370 /* AudioPlayer */ = { + isa = PBXGroup; + children = ( + 98BFB41B2BCAAD8A00E812C0 /* Info.plist */, + 984DE9532BDAE57F004B427A /* Dependencies */, + 984DE9522BDAE571004B427A /* Helpers */, + 9816A8A82BC7F4DE00AD1299 /* Common */, + 9806E8282BC68F7300757370 /* Content */, + 9806E8272BC68F6600757370 /* Navigation */, + 9806E8172BC5D12500757370 /* App.swift */, + 9806E81B2BC5D12700757370 /* Assets.xcassets */, + 9816A8B02BC832E100AD1299 /* Resources */, + 9806E81D2BC5D12700757370 /* Preview Content */, + ); + path = AudioPlayer; + sourceTree = ""; + }; + 9806E81D2BC5D12700757370 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 9806E81E2BC5D12700757370 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 9806E8272BC68F6600757370 /* Navigation */ = { + isa = PBXGroup; + children = ( + 9806E8192BC5D12500757370 /* ContentView.swift */, + 98E6119B2BC72C0E0036BC47 /* DetailView.swift */, + 9806E8252BC5D2A900757370 /* Sidebar.swift */, + ); + path = Navigation; + sourceTree = ""; + }; + 9806E8282BC68F7300757370 /* Content */ = { + isa = PBXGroup; + children = ( + 98E3921C2BD845E100B586E9 /* AudioPlayer */, + ); + path = Content; + sourceTree = ""; + }; + 9816A8A32BC7D8A200AD1299 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9816A8A82BC7F4DE00AD1299 /* Common */ = { + isa = PBXGroup; + children = ( + 98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */, + 9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */, + 9816A8AB2BC820DF00AD1299 /* AudioContent.swift */, + ); + path = Common; + sourceTree = ""; + }; + 9816A8B02BC832E100AD1299 /* Resources */ = { + isa = PBXGroup; + children = ( + 9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */, + 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */, + 9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */, + ); + path = Resources; + sourceTree = ""; + }; + 984DE9522BDAE571004B427A /* Helpers */ = { + isa = PBXGroup; + children = ( + 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */, + 984DE9542BDAE59C004B427A /* Notifier.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 984DE9532BDAE57F004B427A /* Dependencies */ = { + isa = PBXGroup; + children = ( + 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */, + 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */, + ); + path = Dependencies; + sourceTree = ""; + }; + 98E3921C2BD845E100B586E9 /* AudioPlayer */ = { + isa = PBXGroup; + children = ( + 9806E8302BC6927D00757370 /* AudioPlayerModel.swift */, + 9806E8292BC68F8700757370 /* AudioPlayerView.swift */, + 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */, + 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */, + ); + path = AudioPlayer; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 9806E8132BC5D12500757370 /* AudioPlayer */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */; + buildPhases = ( + 9806E8102BC5D12500757370 /* Sources */, + 9806E8112BC5D12500757370 /* Frameworks */, + 9806E8122BC5D12500757370 /* Resources */, + 9816A8A72BC7D8A200AD1299 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AudioPlayer; + productName = AudioPlayer; + productReference = 9806E8142BC5D12500757370 /* AudioPlayer.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9806E80C2BC5D12500757370 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1530; + LastUpgradeCheck = 1530; + TargetAttributes = { + 9806E8132BC5D12500757370 = { + CreatedOnToolsVersion = 15.3; + }; + }; + }; + buildConfigurationList = 9806E80F2BC5D12500757370 /* Build configuration list for PBXProject "AudioPlayer" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 9806E80B2BC5D12500757370; + productRefGroup = 9806E8152BC5D12500757370 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9806E8132BC5D12500757370 /* AudioPlayer */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 9806E8122BC5D12500757370 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */, + 9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */, + 9816A8B12BC8330C00AD1299 /* bensound-jazzyfrenchy.mp3 in Resources */, + 9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */, + 9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 9806E8102BC5D12500757370 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */, + 98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */, + 98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */, + 9806E81A2BC5D12500757370 /* ContentView.swift in Sources */, + 98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */, + 9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */, + 9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */, + 984DE9552BDAE59C004B427A /* Notifier.swift in Sources */, + 9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */, + 9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */, + 98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */, + 9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */, + 9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */, + 984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */, + 9806E8182BC5D12500757370 /* App.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 9806E8202BC5D12700757370 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 9806E8212BC5D12700757370 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 9806E8232BC5D12700757370 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\""; + DEVELOPMENT_TEAM = TJ7GUC6B8Y; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AudioPlayer/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9806E8242BC5D12700757370 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\""; + DEVELOPMENT_TEAM = TJ7GUC6B8Y; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AudioPlayer/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 9806E80F2BC5D12500757370 /* Build configuration list for PBXProject "AudioPlayer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9806E8202BC5D12700757370 /* Debug */, + 9806E8212BC5D12700757370 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9806E8232BC5D12700757370 /* Debug */, + 9806E8242BC5D12700757370 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9806E80C2BC5D12500757370 /* Project object */; +} diff --git a/AudioExample/AudioExample.xcodeproj/xcshareddata/xcschemes/AudioExample.xcscheme b/AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme similarity index 50% rename from AudioExample/AudioExample.xcodeproj/xcshareddata/xcschemes/AudioExample.xcscheme rename to AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme index 52549b0..5f1763e 100644 --- a/AudioExample/AudioExample.xcodeproj/xcshareddata/xcschemes/AudioExample.xcscheme +++ b/AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + BlueprintIdentifier = "9806E8132BC5D12500757370" + BuildableName = "AudioPlayer.app" + BlueprintName = "AudioPlayer" + ReferencedContainer = "container:AudioPlayer.xcodeproj"> @@ -27,64 +28,28 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - enableThreadSanitizer = "YES" - codeCoverageEnabled = "YES"> - - - - - - - - - - - - - - + shouldAutocreateTestPlan = "YES"> + BlueprintIdentifier = "9806E8132BC5D12500757370" + BuildableName = "AudioPlayer.app" + BlueprintName = "AudioPlayer" + ReferencedContainer = "container:AudioPlayer.xcodeproj"> - - - - + BlueprintIdentifier = "9806E8132BC5D12500757370" + BuildableName = "AudioPlayer.app" + BlueprintName = "AudioPlayer" + ReferencedContainer = "container:AudioPlayer.xcodeproj"> diff --git a/AudioPlayer/AudioPlayer/App.swift b/AudioPlayer/AudioPlayer/App.swift new file mode 100644 index 0000000..eb25da7 --- /dev/null +++ b/AudioPlayer/AudioPlayer/App.swift @@ -0,0 +1,55 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import AudioStreaming +import SwiftUI + +@main +struct AudioPlayerApp: App { + + @State var model = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(model) + } + } +} + +@Observable +class AppModel { + @ObservationIgnored + let audioPlayerService: AudioPlayerService + @ObservationIgnored + let equalizerService: EqualizerService + + init( + audioPlayerService: AudioPlayerService = provideAudioPlayerService(), + equalizerService: (AudioPlayerService) -> EqualizerService = provideEqualizerService + ) { + self.audioPlayerService = audioPlayerService + self.equalizerService = equalizerService(audioPlayerService) + } +} + +func provideEqualizerService(playerService: AudioPlayerService) -> EqualizerService { + EqualizerService(playerService: playerService) +} + +func provideAudioPlayerService() -> AudioPlayerService { + AudioPlayerService( + audioPlayer: provideDefaultAudioPlayer() + ) +} + +func provideDefaultAudioPlayer() -> AudioPlayer { + AudioPlayer( + configuration: .init( + flushQueueOnSeek: false, + enableLogs: true + ) + ) +} diff --git a/AudioPlayer/AudioPlayer/Assets.xcassets/AccentColor.colorset/Contents.json b/AudioPlayer/AudioPlayer/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AudioPlayer/AudioPlayer/Assets.xcassets/AppIcon.appiconset/Contents.json b/AudioPlayer/AudioPlayer/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AudioExample/AudioExample/Resources/Assets.xcassets/Contents.json b/AudioPlayer/AudioPlayer/Assets.xcassets/Contents.json similarity index 100% rename from AudioExample/AudioExample/Resources/Assets.xcassets/Contents.json rename to AudioPlayer/AudioPlayer/Assets.xcassets/Contents.json diff --git a/AudioPlayer/AudioPlayer/Common/AddNewAudioURLView.swift b/AudioPlayer/AudioPlayer/Common/AddNewAudioURLView.swift new file mode 100644 index 0000000..4021fc7 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Common/AddNewAudioURLView.swift @@ -0,0 +1,76 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import SwiftUI + +struct AddNewAudioURLView: View { + @Environment(\.dismiss) var dismiss + + private let urlStyle = URL.FormatStyle(path: .omitWhen(.path, matches: ["/"]), query: .omitWhen(.query, matches: [""])) + + @State private var audioUrl: URL? + + var onAddNewUrl: (URL) -> Void + + init(onAddNewUrl: @escaping (URL) -> Void) { + self.onAddNewUrl = onAddNewUrl + } + + var body: some View { + NavigationStack { + VStack { + VStack(alignment: .leading) { + TextField(value: $audioUrl, format: urlStyle, prompt: nil, label: { + Text("Insert URL") + }) + .keyboardType(.URL) + .autocorrectionDisabled() + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onSubmit { + if let url = audioUrl { + onAddNewUrl(url) + dismiss() + } + } + } + .padding(.horizontal, 16) + Button { + if let url = audioUrl { + onAddNewUrl(url) + dismiss() + } + } label: { + HStack { + Image(systemName: "plus") + Text("Add") + } + .foregroundStyle(Color.white) + } + .disabled(audioUrl == nil) + .opacity(audioUrl == nil ? 0.5 : 1.0) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.mint) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .navigationTitle("Add Audio URL") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.gray) + } + } + } + } + } +} + +#Preview { + AddNewAudioURLView(onAddNewUrl: { _ in }) +} diff --git a/AudioExample/AudioExample/Services/AudioContent.swift b/AudioPlayer/AudioPlayer/Common/AudioContent.swift similarity index 62% rename from AudioExample/AudioExample/Services/AudioContent.swift rename to AudioPlayer/AudioPlayer/Common/AudioContent.swift index 662f0f5..1375d3c 100644 --- a/AudioExample/AudioExample/Services/AudioContent.swift +++ b/AudioPlayer/AudioPlayer/Common/AudioContent.swift @@ -1,18 +1,16 @@ // -// AudioContent.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. // import Foundation -enum AudioContent: Int, CaseIterable { +enum AudioContent { case offradio case enlefko case pepper966 case kosmos + case kosmosJazz case radiox case khruangbin case piano @@ -21,62 +19,71 @@ enum AudioContent: Int, CaseIterable { case remoteWave case local case localWave + case custom(String) var title: String { switch self { case .offradio: - return "Offradio (stream)" + return "Offradio" case .enlefko: - return "Enlefko (stream)" + return "Enlefko" case .pepper966: - return "Pepper 96.6 (stream)" + return "Pepper 96.6" case .kosmos: - return "Kosmos 93.6 (stream)" + return "Kosmos 93.6" + case .kosmosJazz: + return "Kosmos Jazz" case .radiox: - return "Radio X (stream)" + return "Radio X" case .khruangbin: - return "Khruangbin (mp3 preview)" + return "Khruangbin" case .piano: - return "Piano (mp3)" + return "Piano" case .remoteWave: - return "Sample remote (wave)" + return "Sample remote" case .local: - return "Jazzy Frenchy (local mp3)" + return "Jazzy Frenchy" case .localWave: - return "Local file (local wave)" + return "Local file" case .optimized: - return "Jazze French (m4a - optimized)" + return "Jazzy Frenchy" case .nonOptimized: - return "Jazze French (m4a - non-optimized)" + return "Jazzy Frenchy" + case .custom(let url): + return url } } var subtitle: String? { switch self { case .offradio: - return nil + return "Stream • offradio.gr" case .enlefko: - return nil + return "Stream • enlefko.fm" case .pepper966: - return nil + return "Stream • pepper966.gr" case .kosmos: - return nil + return "Stream • ertecho.gr" + case .kosmosJazz: + return "Stream • ertecho.gr" case .radiox: - return nil + return "Stream • globalplayer.com" case .khruangbin: - return nil + return "Remote mp3" case .piano: - return nil + return "Remote mp3" case .remoteWave: - return nil + return "wave" case .local: return "Music by: bensound.com" case .localWave: return "Music by: bensound.com" case .optimized: - return "Music by: bensound.com" + return "Music by: bensound.com - m4a optimized" case .nonOptimized: - return "Music by: bensound.com" + return "Music by: bensound.com - m4a non-optimized" + case .custom: + return "" } } @@ -90,6 +97,8 @@ enum AudioContent: Int, CaseIterable { return URL(string: "https://n04.radiojar.com/pepper.m4a?1662039818=&rj-tok=AAABgvlUaioALhdOXDt0mgajoA&rj-ttl=5")! case .kosmos: return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")! + case .kosmosJazz: + return URL(string: "https://radiostreaming.ert.gr/ert-webjazz")! case .radiox: return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")! case .khruangbin: @@ -101,13 +110,15 @@ enum AudioContent: Int, CaseIterable { case .nonOptimized: return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/bensound-jazzyfrenchy.m4a")! case .local: - let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "m4a")! + let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")! return URL(fileURLWithPath: path) case .localWave: let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")! return URL(fileURLWithPath: path) case .remoteWave: - return URL(string: "https://file-examples.com/wp-content/storage/2017/11/file_example_WAV_5MG.wav")! + return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/5-MB-WAV.wav")! + case .custom(let url): + return URL(string: url)! } } } diff --git a/AudioPlayer/AudioPlayer/Common/AudioTrack.swift b/AudioPlayer/AudioPlayer/Common/AudioTrack.swift new file mode 100644 index 0000000..c7456eb --- /dev/null +++ b/AudioPlayer/AudioPlayer/Common/AudioTrack.swift @@ -0,0 +1,118 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + + +import SwiftUI + +enum AudioTrackStatus { + case playing + case paused + case buffering + case error + case idle + + var isPlaying: Bool { + self == .playing || self == .paused + } + + var isError: Bool { + self == .error + } +} + +@Observable +public class AudioTrack: Identifiable, Equatable { + public static func == (lhs: AudioTrack, rhs: AudioTrack) -> Bool { + lhs.id == rhs.id + } + + public var id: String { + url.absoluteString + } + let title: String + let subtitle: String? + let url: URL + + var status: AudioTrackStatus + + private let content: AudioContent + + init(from content: AudioContent, status: AudioTrackStatus = .idle) { + self.title = content.title + self.subtitle = content.subtitle + self.status = status + self.url = content.streamUrl + self.content = content + } +} + + +struct AudioTrackView: View { + @Bindable var track: AudioTrack + + private let action: () -> Void + + init(track: AudioTrack, action: @escaping () -> Void = {}) { + self.track = track + self.action = action + } + + var body: some View { + Button(action: action, label: { + HStack { + VStack(alignment: .leading) { + Text(track.title) + .font(.headline) + .fontWeight(.medium) + .foregroundStyle(.black) + .padding(.top, 8) + .padding(.bottom, track.subtitle == nil ? 8 : 0) + if let subtitle = track.subtitle { + Text(subtitle) + .font(.subheadline) + .fontWeight(.regular) + .foregroundStyle(Color.gray) + } + } + Spacer() + status + } + }) + } + + @ViewBuilder var status: some View { + if track.status == .error { + Image(systemName: "exclamationmark.circle") + .font(.headline) + .foregroundStyle(.red) + } else { + if track.status.isPlaying { + Image(systemName: track.status == .playing ? "play.fill" : "pause.fill") + .font(.headline) + .foregroundStyle(.mint) + } else if track.status == .buffering { + ProgressView() + .progressViewStyle(.circular) + } + } + } +} + +#Preview { + List { + AudioTrackView( + track: AudioTrack(from: .enlefko) + ) + AudioTrackView( + track: AudioTrack(from: .enlefko, status: .playing) + ) + AudioTrackView( + track: AudioTrack(from: .enlefko, status: .paused) + ) + AudioTrackView( + track: AudioTrack(from: .enlefko, status: .error) + ) + } +} diff --git a/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerControlsView.swift b/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerControlsView.swift new file mode 100644 index 0000000..35a4d0b --- /dev/null +++ b/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerControlsView.swift @@ -0,0 +1,285 @@ +// +// Created by Dimitris Chatzieleftheriou on 26/04/2024. +// + +import SwiftUI + +struct AudioPlayerControls: View { + @State var model: Model + @Binding var currentTrack: AudioTrack? + + init(appModel: AppModel, currentTrack: Binding) { + self._model = State(wrappedValue: Model(audioPlayerService: appModel.audioPlayerService)) + self._currentTrack = currentTrack + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Button(action: { model.playPause() }) { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(.title) + .imageScale(.small) + } + .contentTransition(.symbolEffect(.replace)) + Button(action: { + model.stop() + currentTrack = nil + }) { + Image(systemName: "stop.fill") + .font(.title) + .imageScale(.small) + } + .padding(.leading, 8) + Spacer() + Button(action: { model.mute() }) { + Image(systemName: model.isMuted ? "speaker.slash.fill" : "speaker.fill") + .font(.title) + .imageScale(.small) + } + .frame(width: 20, height: 20) + .contentTransition(.symbolEffect(.replace)) + } + .tint(.mint) + .padding(16) + if let audioMetadata = model.liveAudioMetadata, model.isLiveAudioStreaming { + Text("Now Playing: \(audioMetadata)") + .font(.caption) + .foregroundStyle(.black) + .padding(.horizontal, 16) + } + Divider() + VStack { + Slider( + value: $model.currentTime, + in: 0...(model.totalTime ?? 1.0), + onEditingChanged: { scrubStarted in + if scrubStarted { + model.scrubState = .started + } else { + model.scrubState = .ended(model.currentTime) + } + } + ) + .disabled(model.totalTime == nil) + HStack { + Text(model.formattedCurrentTime ?? "--:--") + Spacer() + Text(model.formattedTotalTime ?? "") + } + .foregroundStyle(.black) + .font(.caption) + .fontWeight(.medium) + } + .padding(.bottom, 8) + .padding(.horizontal, 16) + Divider() + VStack(alignment: .leading) { + Text("Playback Rate: \(String(format: "%0.1f", model.playbackRate))") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.black) + Slider(value: $model.playbackRate, in: 1.0...4.0, step: 0.2) + .onChange(of: model.playbackRate) { _, new in + model.update(rate: Float(new)) + } + } + .padding(.bottom, 8) + .padding(.horizontal, 16) + } + .onChange(of: currentTrack) { oldValue, newValue in + if let track = newValue { + model.play(track) + } + } + } +} + +enum ScrubState: Equatable { + case idle + case started + case ended(Double) +} + +extension AudioPlayerControls { + @Observable + final class Model { + @ObservationIgnored + private(set) var audioPlayerService: AudioPlayerService + @ObservationIgnored + private var displayLink: DisplayLink? + + var isLiveAudioStreaming: Bool { + totalTime == 0 + } + + var liveAudioMetadata: String? + + var isPlaying: Bool = false + var isMuted: Bool = false + + var playbackRate: Double = 0.0 + + var currentTime: Double = 0 + var totalTime: Double? + + var scrubState: ScrubState = .idle + + var formattedCurrentTime: String? + var formattedTotalTime: String? + + var currentTrack: AudioTrack? + + init(audioPlayerService: AudioPlayerService) { + self.audioPlayerService = audioPlayerService + + registerObservations() + } + + deinit { + displayLink?.deactivate() + displayLink = nil + } + + func registerObservations() { + Task { @MainActor in + for await status in await audioPlayerService.statusChangedNotifier.values() { + isPlaying = status == .playing + displayLink?.isPaused = !isPlaying + switch status { + case .bufferring: + currentTrack?.status = .buffering + case .error: + currentTrack?.status = .error + currentTrack = nil + case .playing: + currentTrack?.status = .playing + case .paused: + currentTrack?.status = .paused + case .stopped: + currentTrack?.status = .idle + default: + currentTrack?.status = .idle + } + } + } + + Task { @MainActor in + for await metadata in await audioPlayerService.metadataReceivedNotifier.values() { + guard !metadata.isEmpty else { break } + if let title = metadata["StreamTitle"] { + liveAudioMetadata = title.isEmpty ? "-" : title + } else { + liveAudioMetadata = nil + } + } + } + + Task { @MainActor in + for await startStopped in await audioPlayerService.playingStartedStopped.values() { + if startStopped.started { + self.didStartPlaying() + } else { + self.didStopPlaying() + } + } + } + } + + func mute() { + isMuted.toggle() + audioPlayerService.toggleMute() + } + + func playPause() { + if audioPlayerService.state == .playing { + audioPlayerService.pause() + } else { + audioPlayerService.resume() + } + } + + func update(rate: Float) { + let rate = round(rate / 0.2) * 0.2 + audioPlayerService.update(rate: rate) + } + + func stop() { + isPlaying = false + audioPlayerService.stop() + currentTrack?.status = .idle + currentTrack = nil + } + + func play(_ track: AudioTrack) { + if track != currentTrack { + currentTrack?.status = .idle + audioPlayerService.play(url: track.url) + currentTrack = track + } + } + + func onTick() { + let duration = audioPlayerService.duration + let progress = audioPlayerService.progress + if duration > 0 { + let elapsed = Int(progress) + let remaining = Int(duration - progress) + totalTime = duration + switch scrubState { + case .idle: + currentTime = progress + case .started: + break + case .ended(let seekTime): + currentTime = seekTime + if audioPlayerService.duration > 0 { + audioPlayerService.seek(at: seekTime) + } + scrubState = .idle + } + formattedCurrentTime = timeFrom(seconds: Int(elapsed)) + formattedTotalTime = timeFrom(seconds: remaining) + } else { + let elapsed = Int(progress) + formattedCurrentTime = timeFrom(seconds: Int(elapsed)) + if formattedTotalTime != nil { + formattedTotalTime = nil + } + } + } + + func resetLabels() { + currentTime = 0 + totalTime = 0 + formattedCurrentTime = nil + formattedTotalTime = nil + } + + private func timeFrom(seconds: Int) -> String { + let correctSeconds = seconds % 60 + let minutes = (seconds / 60) % 60 + let hours = seconds / 3600 + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds) + } + return String(format: "%02d:%02d", minutes, correctSeconds) + } + + private func didStartPlaying() { + self.displayLink = DisplayLink(onTick: { [weak self] _ in + self?.onTick() + }) + displayLink?.activate() + } + + private func didStopPlaying() { + resetLabels() + liveAudioMetadata = nil + playbackRate = 1.0 + displayLink?.deactivate() + } + } + +} diff --git a/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerModel.swift b/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerModel.swift new file mode 100644 index 0000000..3c4deff --- /dev/null +++ b/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerModel.swift @@ -0,0 +1,69 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import UIKit +import Foundation +import AudioStreaming + +struct AudioPlaylist: Equatable, Identifiable { + var id: String { title } + let title: String + var tracks: [AudioTrack] +} + +@Observable +public class AudioPlayerModel { + @ObservationIgnored + private(set) var audioPlayerService: AudioPlayerService + + var audioTracks: [AudioPlaylist] = [] + + var currentTrack: AudioTrack? + + init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: AudioPlayerService) { + self.audioPlayerService = audioPlayerService + self.audioTracks = audioTracksProvider() + } + + deinit { + audioPlayerService.stop() + } + + func addNewAudioTrack(url: URL) { + let customIndex = audioTracks.firstIndex(where: { $0.id == "Custom" }) + let audioTrack = AudioTrack(from: .custom(url.absoluteString), status: .idle) + let playlist = AudioPlaylist(title: "Custom", tracks: [audioTrack]) + if let customIndex { + let tracks = audioTracks[customIndex].tracks + if !tracks.contains(audioTrack) { + audioTracks[customIndex].tracks.append(audioTrack) + } + } else { + audioTracks.append(playlist) + } + } + + func play(_ track: AudioTrack) { + if track != currentTrack { + currentTrack = track + } + } +} + +private let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .kosmosJazz, .radiox] +private let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave] + +func audioTracksProvider() -> [AudioPlaylist] { + [ + AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }), + AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) }) + ] +} + +func audioQueueTrackProvider() -> [AudioPlaylist] { + [ + AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) }) + ] +} diff --git a/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerView.swift b/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerView.swift new file mode 100644 index 0000000..3853fa7 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Content/AudioPlayer/AudioPlayerView.swift @@ -0,0 +1,86 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import SwiftUI + +struct AudioPlayerView: View { + @Environment(AppModel.self) var appModel + + @State var model: AudioPlayerModel + + @State var eqSheetIsShown: Bool = false + @State var addNewAudioIsShown: Bool = false + + init(appModel: AppModel) { + self._model = State(wrappedValue: AudioPlayerModel(audioPlayerService: appModel.audioPlayerService)) + } + + var body: some View { + ScrollViewReader { proxy in + List { + ForEach(model.audioTracks) { section in + Section { + ForEach(section.tracks) { track in + AudioTrackView(track: track) { + model.play(track) + } + .id(track.id) + } + } header: { + Text(section.title) + } + } + } + .onChange(of: model.audioTracks) { _, newValue in + if let lastId = newValue.last?.tracks.last?.id { + withAnimation { + proxy.scrollTo(lastId, anchor: .bottom) + } + } + } + } + .safeAreaInset(edge: .bottom) { + AudioPlayerControls(appModel: appModel, currentTrack: $model.currentTrack) + .background( + .ultraThinMaterial.shadow( + ShadowStyle.drop(color: .black.opacity(0.1), radius: 8, x: 0, y: -10) + ) + ) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle("Audio Player") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + eqSheetIsShown.toggle() + } label: { + Image(systemName: "slider.horizontal.3") + } + Button { + addNewAudioIsShown.toggle() + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $eqSheetIsShown) { + EqualizerView(appModel: appModel) + .presentationDetents([.medium]) + } + .sheet(isPresented: $addNewAudioIsShown) { + AddNewAudioURLView( + onAddNewUrl: { url in + model.addNewAudioTrack(url: url) + } + ) + .presentationDetents([.height(150)]) + } + } +} + +#Preview { + AudioPlayerView(appModel: AppModel()) +} diff --git a/AudioPlayer/AudioPlayer/Content/AudioPlayer/EqualizerView.swift b/AudioPlayer/AudioPlayer/Content/AudioPlayer/EqualizerView.swift new file mode 100644 index 0000000..1e0bf36 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Content/AudioPlayer/EqualizerView.swift @@ -0,0 +1,327 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import SwiftUI + +@Observable +class EQBand: Identifiable { + var frequency: String + var min: Float + var max: Float + var value: Float + + @ObservationIgnored + let index: Int + + init(index: Int, frequency: String, min: Float, max: Float, value: Float) { + self.index = index + self.frequency = frequency + self.min = min + self.max = max + self.value = value + } +} + +struct EqualizerView: View { + @Environment(\.dismiss) var dismiss + + @Environment(AppModel.self) var appModel + + @State var model: Model + + init(appModel: AppModel) { + self._model = State(wrappedValue: Model(equalizerService: appModel.equalizerService)) + } + + var body: some View { + NavigationStack { + VStack(spacing: 16) { + EQSliderView() + .frame(height: 180) + .padding(.horizontal, 16) + .environment(model) + + HStack(alignment: .center, spacing: 16) { + Button { + withAnimation { + model.isEnabled.toggle() + model.enable() + } + } label: { + HStack { + Image(systemName: model.isEnabled ? "waveform.slash" : "waveform") + .contentTransition(.symbolEffect(.replace)) + Text(model.isEnabled ? "Disable": "Enable") + .font(.body) + } + .foregroundStyle(Color.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(model.isEnabled ? .red : .mint) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + Button { + model.reset() + } label: { + HStack { + Text("Reset") + .font(.body) + } + .foregroundStyle(Color.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(.mint) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .padding(.top, 24) + } + .task { + Task { + model.generateBands() + } + } + .navigationTitle("Equalizer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.gray) + } + } + } + } + } +} + +struct EQSliderView: View { + @Environment(EqualizerView.Model.self) var eqModel + + @State private var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6) + @State private var resetPoints: [Double] = Array(repeating: .zero, count: 6) + + @State private var eqViewFrame: CGRect = .zero + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + // Draw labels for gain values + VStack { + Text("\(Int(eqModel.maxGain))db") + .font(.caption2) + .foregroundColor(.black) + Spacer() + Text("0dB") + .font(.caption2) + .foregroundColor(.black) + Spacer() + Text("\(Int(eqModel.minGain))db") + .font(.caption2) + .foregroundColor(.black) + } + GeometryReader { innerGeo in + ZStack { + LineShape(values: eqModel.shouldReset ? resetPoints : dragPointYLocations.map { Double($0) }) + .stroke(Color.mint, lineWidth: 2) + .animation(.easeInOut(duration: 0.2), value: eqModel.shouldReset) + .onAppear { + resetPoints = resetPoints.map { _ in Double(gainToYPosition(at: 0, in: innerGeo.size)) } + } + + Path { path in + for index in 0.. CGFloat { + size.width / 12 * CGFloat(index * 2 + 1) + } + + func updateGainValue(at index: Int, in size: CGSize) { + let percentage = dragPointYLocations[index] / size.height + let gain = (1 - Float(percentage)) * (eqModel.maxGain - eqModel.minGain) + eqModel.minGain + eqModel.update(gain: gain, index: index) + } + + func gainToYPosition(at gain: Float, in size: CGSize) -> CGFloat { + let percentage = 1 - (gain - eqModel.minGain) / (eqModel.maxGain - eqModel.minGain) + return CGFloat(percentage) * size.height + } + + func resetPositions(in size: CGSize) { + let reset = dragPointYLocations.map { _ in gainToYPosition(at: 0, in: size) } + withAnimation(.easeInOut(duration: 0.2)) { + dragPointYLocations = reset + } + + } +} + +extension EqualizerView { + @Observable + class Model { + @ObservationIgnored + private let equalizerService: EqualizerService + + var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6) + + var isEnabled: Bool = false + + var bands: [EQBand] = [] + + let minGain: Float = -12 + let maxGain: Float = 12 + + var shouldReset: Bool = false + + init(equalizerService: EqualizerService) { + self.equalizerService = equalizerService + isEnabled = equalizerService.isActivated + } + + func generateBands() { + bands = equalizerService.bands.enumerated().map { index, item in + var measurement = item.frequency + var frequency = String(Int(measurement)) + if item.frequency >= 1000 { + measurement = item.frequency / 1000 + frequency = "\(String(Int(measurement)))K" + } + return EQBand(index: index, frequency: frequency, min: minGain, max: maxGain, value: item.gain) + } + } + + func enable() { + if isEnabled { + equalizerService.activate() + } else { + equalizerService.deactivate() + } + } + + func update(gain: Float, index: Int) { + shouldReset = false + bands[index].value = gain + equalizerService.update(gain: gain, for: index) + } + + func reset() { + guard !shouldReset else { + return + } + shouldReset = true + equalizerService.reset() + for band in bands { + band.value = 0.0 + } + } + } +} + +struct LineShape: Shape { + var values: [Double] + + var animatableData: AnimatableLine { + get { AnimatableLine(values: values) } + set { values = newValue.values } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.size.width / 12, y: values.first ?? 0)) + for index in 1.. CGFloat { + size.width / 12 * CGFloat(index * 2 + 1) + } +} + +struct AnimatableLine : VectorArithmetic { + var values: [Double] + + var magnitudeSquared: Double { + return values.map { $0 * $0 }.reduce(0, +) + } + + mutating func scale(by rhs: Double) { + values = values.map { $0 * rhs } + } + + static var zero: AnimatableLine { + return AnimatableLine(values: [0.0]) + } + + static func - (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine { + return AnimatableLine(values: zip(lhs.values, rhs.values).map(-)) + } + + static func -= (lhs: inout AnimatableLine, rhs: AnimatableLine) { + lhs = lhs - rhs + } + + static func + (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine { + return AnimatableLine(values: zip(lhs.values, rhs.values).map(+)) + } + + static func += (lhs: inout AnimatableLine, rhs: AnimatableLine) { + lhs = lhs + rhs + } +} + +#Preview { + EqualizerView(appModel: AppModel()) +} diff --git a/AudioExample/AudioExample/Services/AudioPlayerService.swift b/AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift similarity index 77% rename from AudioExample/AudioExample/Services/AudioPlayerService.swift rename to AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift index 1578924..0db2e44 100644 --- a/AudioExample/AudioExample/Services/AudioPlayerService.swift +++ b/AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift @@ -1,24 +1,21 @@ // -// AudioPlayerService.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 14/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. // import AudioStreaming import AVFoundation protocol AudioPlayerServiceDelegate: AnyObject { - func didStartPlaying() - func didStopPlaying() + func didStartPlaying(id: AudioEntryId) + func didStopPlaying(id: AudioEntryId, reason: AudioPlayerStopReason) func statusChanged(status: AudioPlayerState) func errorOccurred(error: AudioPlayerError) func metadataReceived(metadata: [String: String]) } final class AudioPlayerService { - var delegate = MulticastDelegate() + weak var delegate: AudioPlayerServiceDelegate? private var player: AudioPlayer private var audioSystemResetObserver: Any? @@ -43,8 +40,12 @@ final class AudioPlayerService { player.state } - init() { - player = AudioPlayer(configuration: .init(enableLogs: true)) + var statusChangedNotifier = Notifier() + var metadataReceivedNotifier = Notifier<[String: String]>() + var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>() + + init(audioPlayer: AudioPlayer) { + player = audioPlayer player.delegate = self configureAudioSession() @@ -98,8 +99,8 @@ final class AudioPlayerService { } } - func seek(at time: Float) { - player.seek(to: Double(time)) + func seek(at time: Double) { + player.seek(to: time) } private func recreatePlayer() { @@ -109,10 +110,11 @@ final class AudioPlayerService { private func registerSessionEvents() { // Note that a real app might need to observer other AVAudioSession notifications as well - audioSystemResetObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, - object: nil, - queue: nil) - { [unowned self] _ in + audioSystemResetObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, + object: nil, + queue: nil + ) { [unowned self] _ in self.configureAudioSession() self.recreatePlayer() } @@ -151,14 +153,16 @@ final class AudioPlayerService { extension AudioPlayerService: AudioPlayerDelegate { func audioPlayerDidStartPlaying(player _: AudioPlayer, with id: AudioEntryId) { print("audioPlayerDidStartPlaying entryId: \(id)") - delegate.invoke(invocation: { $0.didStartPlaying() }) + delegate?.didStartPlaying(id: id) + Task { await playingStartedStopped.send((true, id, nil)) } } func audioPlayerDidFinishBuffering(player _: AudioPlayer, with _: AudioEntryId) {} func audioPlayerStateChanged(player _: AudioPlayer, with newState: AudioPlayerState, previous _: AudioPlayerState) { print("audioPlayerDidStartPlaying newState: \(newState)") - delegate.invoke(invocation: { $0.statusChanged(status: newState) }) + Task { await statusChangedNotifier.send(newState) } + delegate?.statusChanged(status: newState) } func audioPlayerDidFinishPlaying(player _: AudioPlayer, @@ -168,16 +172,19 @@ extension AudioPlayerService: AudioPlayerDelegate { duration _: Double) { print("audioPlayerDidFinishPlaying entryId: \(id), reason: \(reason)") - delegate.invoke(invocation: { $0.didStopPlaying() }) + Task { await playingStartedStopped.send((false, id, reason)) } + delegate?.didStopPlaying(id: id, reason: reason) } func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) { - delegate.invoke(invocation: { $0.errorOccurred(error: error) }) + delegate?.errorOccurred(error: error) } func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {} func audioPlayerDidReadMetadata(player _: AudioPlayer, metadata: [String: String]) { - delegate.invoke(invocation: { $0.metadataReceived(metadata: metadata) }) + Task { await metadataReceivedNotifier.send(metadata) } + delegate?.metadataReceived(metadata: metadata) } } + diff --git a/AudioExample/AudioExample/Services/EqualizerService.swift b/AudioPlayer/AudioPlayer/Dependencies/EqualizerService.swift similarity index 80% rename from AudioExample/AudioExample/Services/EqualizerService.swift rename to AudioPlayer/AudioPlayer/Dependencies/EqualizerService.swift index 90599b4..c52a3ef 100644 --- a/AudioExample/AudioExample/Services/EqualizerService.swift +++ b/AudioPlayer/AudioPlayer/Dependencies/EqualizerService.swift @@ -1,16 +1,13 @@ // -// EqualizerService.swift -// AudioExample -// -// Created by Dimitrios Chatzieleftheriou on 15/11/2020. -// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved. +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. // import AVFoundation final class EqualizerService { private let playerService: AudioPlayerService - private let _freqs = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000] + private let _freqs = [60, 150, 400, 1000, 2400, 15000] private let eqUnit: AVAudioUnitEQ var bands: [AVAudioUnitEQFilterParameters] { diff --git a/AudioPlayer/AudioPlayer/Helpers/DisplayLink.swift b/AudioPlayer/AudioPlayer/Helpers/DisplayLink.swift new file mode 100644 index 0000000..29cbd06 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Helpers/DisplayLink.swift @@ -0,0 +1,49 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import UIKit + +final class DisplayLink { + + private var displayLink: CADisplayLink? + private var target = DisplayLinkTarget() + + var isPaused: Bool = true { + didSet { + displayLink?.isPaused = isPaused + } + } + + init(onTick: @escaping (CADisplayLink) -> Void) { + target.onTick = onTick + } + + deinit { + deactivate() + } + + func activate() { + displayLink?.invalidate() + displayLink = nil + displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:))) + displayLink?.preferredFrameRateRange = .init(minimum: 6, maximum: 10) + displayLink?.add(to: .current, forMode: .common) + self.isPaused = false + } + + func deactivate() { + isPaused = true + displayLink?.invalidate() + displayLink = nil + } +} + +private final class DisplayLinkTarget { + var onTick: ((CADisplayLink) -> Void)? + + @objc func tick(_ link: CADisplayLink) { + onTick?(link) + } +} diff --git a/AudioPlayer/AudioPlayer/Helpers/Notifier.swift b/AudioPlayer/AudioPlayer/Helpers/Notifier.swift new file mode 100644 index 0000000..ec03c40 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Helpers/Notifier.swift @@ -0,0 +1,29 @@ +// +// Created by Dimitris Chatzieleftheriou on 25/04/2024. +// + +import Foundation + +actor Notifier { + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + func values(bufferingPolicy limit: AsyncStream.Continuation.BufferingPolicy = .bufferingNewest(1)) -> AsyncStream { + AsyncStream(bufferingPolicy: limit) { continuation in + let id = UUID() + continuations[id] = continuation + continuation.onTermination = { _ in + Task { await self.cancel(id) } + } + } + } + + func send(_ value: Output) { + for continuation in continuations.values { + continuation.yield(value) + } + } + + private func cancel(_ id: UUID) { + continuations[id] = nil + } +} diff --git a/AudioPlayer/AudioPlayer/Info.plist b/AudioPlayer/AudioPlayer/Info.plist new file mode 100644 index 0000000..f753731 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + audio + + + diff --git a/AudioPlayer/AudioPlayer/Navigation/ContentView.swift b/AudioPlayer/AudioPlayer/Navigation/ContentView.swift new file mode 100644 index 0000000..76a2688 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Navigation/ContentView.swift @@ -0,0 +1,35 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + + @Environment(AppModel.self) var appModel + + @State private var selection: NavigationContent? + + var body: some View { + NavigationStack { + List { + NavigationLink(value: NavigationContent.audioPlayer) { + Label("Audio Player", systemImage: "play") + } + + NavigationLink(value: NavigationContent.audioQueue) { + Label("Audio Queue", systemImage: "play.square.stack") + } + } + .navigationTitle("Home") + .navigationDestination(for: NavigationContent.self) { content in + DetailView(selection: content) + } + } + } +} + +#Preview { + ContentView() +} diff --git a/AudioPlayer/AudioPlayer/Navigation/DetailView.swift b/AudioPlayer/AudioPlayer/Navigation/DetailView.swift new file mode 100644 index 0000000..775d7ea --- /dev/null +++ b/AudioPlayer/AudioPlayer/Navigation/DetailView.swift @@ -0,0 +1,22 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + + +import SwiftUI + +struct DetailView: View { + @Environment(AppModel.self) var appModel + + var selection: NavigationContent + + var body: some View { + switch selection { + case .audioPlayer: + AudioPlayerView(appModel: appModel) + case .audioQueue: // TODO + EmptyView() + } + } +} diff --git a/AudioPlayer/AudioPlayer/Navigation/Sidebar.swift b/AudioPlayer/AudioPlayer/Navigation/Sidebar.swift new file mode 100644 index 0000000..e4ac313 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Navigation/Sidebar.swift @@ -0,0 +1,45 @@ +// +// Created by Dimitris C. +// Copyright © 2024 Decimal. All rights reserved. +// + +import SwiftUI + +enum NavigationContent: Hashable { + case audioPlayer + case audioQueue +} + +struct ContentSidebar: View { + @Binding var selection: NavigationContent? + + var body: some View { + List(selection: $selection) { + NavigationLink(value: NavigationContent.audioPlayer) { + Label("Audio Player", systemImage: "play") + } + + NavigationLink(value: NavigationContent.audioQueue) { + Label("Audio Queue", systemImage: "play.square.stack") + } + } + .navigationTitle("Home") + } +} + +struct Sidebar_Previews: PreviewProvider { + struct Preview: View { + @State private var selection: NavigationContent? = NavigationContent.audioPlayer + var body: some View { + ContentSidebar(selection: $selection) + } + } + + static var previews: some View { + NavigationSplitView { + Preview() + } detail: { + Text("Detail!") + } + } +} diff --git a/AudioPlayer/AudioPlayer/Preview Content/Preview Assets.xcassets/Contents.json b/AudioPlayer/AudioPlayer/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/AudioPlayer/AudioPlayer/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AudioExample/AudioExample/Resources/bensound-jazzyfrenchy.m4a b/AudioPlayer/AudioPlayer/Resources/bensound-jazzyfrenchy.m4a similarity index 100% rename from AudioExample/AudioExample/Resources/bensound-jazzyfrenchy.m4a rename to AudioPlayer/AudioPlayer/Resources/bensound-jazzyfrenchy.m4a diff --git a/AudioExample/AudioExample/Resources/bensound-jazzyfrenchy.mp3 b/AudioPlayer/AudioPlayer/Resources/bensound-jazzyfrenchy.mp3 similarity index 100% rename from AudioExample/AudioExample/Resources/bensound-jazzyfrenchy.mp3 rename to AudioPlayer/AudioPlayer/Resources/bensound-jazzyfrenchy.mp3 diff --git a/AudioExample/AudioExample/Resources/hipjazz.wav b/AudioPlayer/AudioPlayer/Resources/hipjazz.wav similarity index 100% rename from AudioExample/AudioExample/Resources/hipjazz.wav rename to AudioPlayer/AudioPlayer/Resources/hipjazz.wav diff --git a/AudioStreaming.xcworkspace/contents.xcworkspacedata b/AudioStreaming.xcworkspace/contents.xcworkspacedata index c76c5cd..ed8f13d 100644 --- a/AudioStreaming.xcworkspace/contents.xcworkspacedata +++ b/AudioStreaming.xcworkspace/contents.xcworkspacedata @@ -2,7 +2,7 @@ + location = "group:AudioPlayer/AudioPlayer.xcodeproj"> diff --git a/AudioStreaming/Core/Structures/Queue.swift b/AudioStreaming/Core/Structures/Queue.swift index bc8ddd7..670e857 100644 --- a/AudioStreaming/Core/Structures/Queue.swift +++ b/AudioStreaming/Core/Structures/Queue.swift @@ -25,13 +25,15 @@ +---+ +---+ ``` */ -final class Queue: Sequence, CustomDebugStringConvertible { +final class Queue: Sequence, CustomDebugStringConvertible { private var _storage: [Element] = [] var isEmpty: Bool { _storage.isEmpty } var count: Int { _storage.count } + var items: [Element] { _storage } + /// Inserts an item at the end of the queue func enqueue(item: Element) { _storage.insert(item, at: 0) @@ -55,6 +57,30 @@ final class Queue: Sequence, CustomDebugStringConvertible { } } + /// Inserts an item at a specific index in the queue + func insert(item: Element, at index: Int) { + guard index >= 0 && index <= count else { + fatalError("Index out of range") + } + _storage.insert(item, at: index) + } + + func remove(item: Element) { + guard let index = _storage.firstIndex(of: item) else { + return + } + _storage.remove(at: index) + } + + /// Removes the item at the specified index in the queue + @discardableResult + func remove(at index: Int) -> Element? { + guard index >= 0 && index < count else { + return nil + } + return _storage.remove(at: index) + } + /// Retrieves the last item func peek() -> Element? { _storage.last diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift index a26fdf8..0471cf0 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift @@ -210,6 +210,25 @@ open class AudioPlayer { } } + public func playNextInQueue() { + checkRenderWaitingAndNotifyIfNeeded() + serializationQueue.sync { + if entriesQueue.count(for: .upcoming) > 0 { + playerContext.setInternalState(to: .pendingNext) + } + do { + try self.startEngineIfNeeded() + } catch { + self.raiseUnexpected(error: .audioSystemError(.engineFailure)) + } + } + + sourceQueue.async { [weak self] in + guard let self = self else { return } + self.processSource() + } + } + /// Queues the specified URL /// /// - Parameter url: A `URL` specifying the audio content to be played. @@ -224,15 +243,41 @@ open class AudioPlayer { queue(urls: urls, headers: [:]) } + public func queue(url: URL, after afterUrl: URL) { + queue(url: url, headers: [:], after: afterUrl) + } + + public func removeFromQueue(url: URL) { + serializationQueue.sync { + if let item = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == url.absoluteString }) { + entriesQueue.remove(item: item, type: .upcoming) + + if playerContext.audioPlayingEntry?.id.id == item.id.id { + stop(clearQueue: false) + } + } + } + checkRenderWaitingAndNotifyIfNeeded() + sourceQueue.async { [weak self] in + self?.processSource() + } + } + /// Queues the specified URL /// /// - Parameter url: A `URL` specifying the audio content to be played. /// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request. - public func queue(url: URL, headers: [String: String]) { + public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) { serializationQueue.sync { let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers) audioEntry.delegate = self - entriesQueue.enqueue(item: audioEntry, type: .upcoming) + if let afterUrl = afterUrl { + if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) { + entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry) + } + } else { + entriesQueue.enqueue(item: audioEntry, type: .upcoming) + } } checkRenderWaitingAndNotifyIfNeeded() sourceQueue.async { [weak self] in @@ -259,7 +304,7 @@ open class AudioPlayer { } /// Stops the audio playback - public func stop() { + public func stop(clearQueue: Bool = true) { guard playerContext.internalState != .stopped else { return } serializationQueue.sync { @@ -274,7 +319,9 @@ open class AudioPlayer { self.processFinishPlaying(entry: playingEntry, with: nil) } - self.clearQueue() + if clearQueue { + self.clearQueue() + } self.playerContext.entriesLock.lock() self.playerContext.audioReadingEntry = nil self.playerContext.audioPlayingEntry = nil diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift index 3026e43..b20c6cc 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift @@ -22,7 +22,7 @@ public struct AudioPlayerConfiguration: Equatable { /// Enables the internal logs let enableLogs: Bool - public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: true, + public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: false, bufferSizeInSeconds: 10, secondsRequiredToStartPlaying: 1, gracePeriodAfterSeekInSeconds: 0.5, diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift index 87e9208..73e902c 100644 --- a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift +++ b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift @@ -109,9 +109,9 @@ final class AudioPlayerRenderProcessor: NSObject { bufferList.mBuffers.mDataByteSize = frameSizeInBytes * framesToCopy if isMuted { - writeSilence(outputBuffer: &bufferList.mBuffers, - outputBufferSize: 0, - offset: Int(bufferList.mBuffers.mDataByteSize)) + if let mData = bufferList.mBuffers.mData { + memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize)) + } } else { if let mDataBuffer = audioBuffer.mData { memcpy(bufferList.mBuffers.mData, @@ -132,9 +132,9 @@ final class AudioPlayerRenderProcessor: NSObject { bufferList.mBuffers.mDataByteSize = frameSizeInBytes * frameToCopy if isMuted { - writeSilence(outputBuffer: &bufferList.mBuffers, - outputBufferSize: 0, - offset: Int(bufferList.mBuffers.mDataByteSize)) + if let mData = bufferList.mBuffers.mData { + memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize)) + } } else { if let mDataBuffer = audioBuffer.mData { memcpy(bufferList.mBuffers.mData, @@ -151,9 +151,7 @@ final class AudioPlayerRenderProcessor: NSObject { bufferList.mBuffers.mDataByteSize += frameSizeInBytes * moreFramesToCopy if let ioBufferData = bufferList.mBuffers.mData { if isMuted { - writeSilence(outputBuffer: &bufferList.mBuffers, - outputBufferSize: Int(frameSizeInBytes * moreFramesToCopy), - offset: Int(frameToCopy * frameSizeInBytes)) + memset(ioBufferData + Int(frameToCopy * frameSizeInBytes), 0, Int(frameSizeInBytes * moreFramesToCopy)) } else { if let mDataBuffer = audioBuffer.mData { memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes), @@ -319,13 +317,4 @@ final class AudioPlayerRenderProcessor: NSObject { guard inputBusNumber == 0 else { return noErr } return render(inNumberFrames: inNumberFrames, ioData: inputData, flags: flags) } - - @inline(__always) - private func writeSilence(outputBuffer: inout AudioBuffer, - outputBufferSize: Int, - offset: Int) - { - guard let mData = outputBuffer.mData else { return } - memset(mData + offset, 0, outputBufferSize) - } } diff --git a/AudioStreaming/Streaming/Helpers/PlayerQueueEntries.swift b/AudioStreaming/Streaming/Helpers/PlayerQueueEntries.swift index 4c0c514..6246420 100644 --- a/AudioStreaming/Streaming/Helpers/PlayerQueueEntries.swift +++ b/AudioStreaming/Streaming/Helpers/PlayerQueueEntries.swift @@ -34,6 +34,17 @@ final class PlayerQueueEntries { upcoming = Queue() } + /// Returns an array containing all items in the queue for the specified `type`. + /// + /// - Note: This method returns the items in the queue without removing them. + /// + /// - Parameter type: A `PlayerQueueType` specifying the type of the queue. + /// - Returns: An array of `AudioEntry` objects representing the items in the queue. + func items(type: PlayerQueueType) -> [AudioEntry] { + lock.lock(); defer { lock.unlock() } + return queue(for: type).items + } + /// Adds the `item` to the underlying queue for the specified `type` /// - parameter item: An `AudioEntry` object to be added /// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType` @@ -51,6 +62,32 @@ final class PlayerQueueEntries { return queue(for: type).dequeue() } + func insert(item: AudioEntry, type: PlayerQueueType, after afterItem: AudioEntry) { + lock.lock(); defer { lock.unlock() } + if let indexForAfterItem = queue(for: type).items.firstIndex(of: afterItem) { + queue(for: .upcoming).insert(item: item, at: indexForAfterItem) + } + } + + /// Inserts the `item` at the specified index in the underlying queue for the specified `type`. + /// - Parameters: + /// - item: An `AudioEntry` object to be added. + /// - type: The type of the underlying queue as expressed by `PlayerQueueType`. + /// - index: The index at which to insert the item. + func insert(item: AudioEntry, type: PlayerQueueType, at index: Int) { + lock.lock(); defer { lock.unlock() } + queue(for: type).insert(item: item, at: index) + } + + /// Removes the item at the specified index from the underlying queue for the specified `type`. + /// - Parameters: + /// - type: The type of the underlying queue as expressed by `PlayerQueueType`. + /// - index: The index of the item to remove. + func remove(item: AudioEntry, type: PlayerQueueType) { + lock.lock(); defer { lock.unlock() } + queue(for: type).remove(item: item) + } + /// Appends (skips) the `items` to the underlying queue for the specified `type` /// - parameter item: An `AudioEntry` object to be added /// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType` diff --git a/AudioStreamingTests/AudioExample.xctestplan b/AudioStreamingTests/AudioExample.xctestplan deleted file mode 100644 index 86dd53a..0000000 --- a/AudioStreamingTests/AudioExample.xctestplan +++ /dev/null @@ -1,41 +0,0 @@ -{ - "configurations" : [ - { - "id" : "A1B13C01-AF5C-46DD-990A-A369639F2AD3", - "name" : "Configuration 1", - "options" : { - - } - } - ], - "defaultOptions" : { - "environmentVariableEntries" : [ - { - "enabled" : false, - "key" : "OS_ACTIVITY_MODE", - "value" : "disable" - } - ], - "targetForVariableExpansion" : { - "containerPath" : "container:AudioExample.xcodeproj", - "identifier" : "B5AEDBD02475274C007D8101", - "name" : "AudioExample" - }, - "testExecutionOrdering" : "random", - "threadSanitizerEnabled" : true - }, - "testTargets" : [ - { - "parallelizable" : true, - "skippedTests" : [ - "ProtectedTests" - ], - "target" : { - "containerPath" : "container:..\/AudioStreaming.xcodeproj", - "identifier" : "B5AEDBB624744153007D8101", - "name" : "AudioStreamingTests" - } - } - ], - "version" : 1 -} diff --git a/AudioStreamingTests/Core/QueueTests.swift b/AudioStreamingTests/Core/QueueTests.swift index 7dba4e7..7960b44 100644 --- a/AudioStreamingTests/Core/QueueTests.swift +++ b/AudioStreamingTests/Core/QueueTests.swift @@ -83,4 +83,27 @@ class QueueTests: XCTestCase { queue.removeAll() XCTAssertTrue(queue.isEmpty) } + + func testInsertingAtSpecificIndex() { + let queue = Queue() + queue.enqueue(item: 1) + queue.enqueue(item: 2) + queue.enqueue(item: 3) + + queue.insert(item: 6, at: 1) + + XCTAssertEqual(queue.count, 4) + XCTAssertEqual(queue.remove(at: 1), 6) + } + + func testRemovingAtSpecificIndex() { + let queue = Queue() + queue.enqueue(item: 1) + queue.enqueue(item: 2) + queue.enqueue(item: 3) + + XCTAssertEqual(queue.remove(at: 1), 2) + + XCTAssertEqual(queue.count, 2) + } } diff --git a/AudioStreamingTests/Streaming/Metadata Stream Processor/MetadataStreamProcessorTests.swift b/AudioStreamingTests/Streaming/Metadata Stream Processor/MetadataStreamProcessorTests.swift index 85e8ffd..40bbac4 100644 --- a/AudioStreamingTests/Streaming/Metadata Stream Processor/MetadataStreamProcessorTests.swift +++ b/AudioStreamingTests/Streaming/Metadata Stream Processor/MetadataStreamProcessorTests.swift @@ -13,6 +13,8 @@ import XCTest class MetadataStreamProcessorTests: XCTestCase { var metadataDelegateSpy = MetadataDelegateSpy() + let bundle = Bundle(for: MetadataStreamProcessorTests.self) + func test_Processor_SendsCorrectValues_IfItCanProcessMetadata() throws { let parser = MetadataParser() let processor = MetadataStreamProcessor(parser: parser.eraseToAnyParser()) @@ -34,7 +36,6 @@ class MetadataStreamProcessorTests: XCTestCase { } func test_Processor_Outputs_Correct_Metadata_ForStep_WithEmptyMetadata() throws { - let bundle = Bundle(for: MetadataStreamProcessorTests.self) let url = bundle.url(forResource: "raw-stream-audio-empty-metadata", withExtension: nil)! let data = try Data(contentsOf: url) @@ -53,7 +54,6 @@ class MetadataStreamProcessorTests: XCTestCase { } func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata() throws { - let bundle = Bundle(for: MetadataStreamProcessorTests.self) let url = bundle.url(forResource: "raw-stream-audio-normal-metadata", withExtension: nil)! let data = try Data(contentsOf: url) @@ -72,7 +72,6 @@ class MetadataStreamProcessorTests: XCTestCase { } func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata_Alt() throws { - let bundle = Bundle(for: MetadataStreamProcessorTests.self) let url = bundle.url(forResource: "raw-stream-audio-normal-metadata-alt", withExtension: nil)! let data = try Data(contentsOf: url) @@ -95,7 +94,6 @@ class MetadataStreamProcessorTests: XCTestCase { } func test_Processor_Outputs_Correct_Metadata_ForStep_NoMetadata() throws { - let bundle = Bundle(for: MetadataStreamProcessorTests.self) let url = bundle.url(forResource: "raw-stream-audio-no-metadata", withExtension: nil)! let data = try Data(contentsOf: url) diff --git a/Package.swift b/Package.swift index 674445b..402ecc9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 import PackageDescription @@ -18,6 +18,18 @@ let package = Package( name: "AudioStreaming", path: "AudioStreaming" ), - ], - swiftLanguageVersions: [.v5] + .testTarget( + name: "AudioStreamingTests", + dependencies: [ + "AudioStreaming" + ], + path: "AudioStreamingTests", + resources: [ + .copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-empty-metadata"), + .copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-no-metadata"), + .copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-normal-metadata"), + .copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-normal-metadata-alt") + ] + ) + ] ) diff --git a/README.md b/README.md index f3aba6e..850bdf4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/workflows/AudioStreaming%20CI/badge.svg) +[![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml/badge.svg)](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml) # AudioStreaming An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing.