From d78f2d553a233d734592f924ee6f8f80a8248c9e Mon Sep 17 00:00:00 2001 From: Ryan F Date: Wed, 30 Oct 2024 20:48:14 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=9A=80=20Package:=20add=20Control=20+?= =?UTF-8?q?=20Controllers=20libraries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set minimum iOS version to 15 --- Package.swift | 22 +- Sources/Control/Control.swift | 12 + .../BetweenZeroAndOneInclusive.swift | 20 ++ Sources/Control/Extensions/MPVolumeView.swift | 46 ++++ Sources/Control/Haptics.swift | 17 ++ Sources/Control/Playback.swift | 77 +++++++ Sources/Control/Volume.swift | 79 +++++++ Sources/ControlKit/ControlKit.swift | 2 - .../Controllers/AppleMusicController.swift | 35 +++ Sources/Controllers/Controllers.swift | 12 + Sources/Controllers/Extensions/DAO.swift | 20 ++ Sources/Controllers/PlaybackController.swift | 13 ++ Sources/Controllers/SpotifyController.swift | 214 ++++++++++++++++++ Sources/Controllers/SystemController.swift | 98 ++++++++ Sources/Controllers/VolumeController.swift | 43 ++++ 15 files changed, 704 insertions(+), 6 deletions(-) create mode 100644 Sources/Control/Control.swift create mode 100644 Sources/Control/Extensions/BetweenZeroAndOneInclusive.swift create mode 100644 Sources/Control/Extensions/MPVolumeView.swift create mode 100644 Sources/Control/Haptics.swift create mode 100644 Sources/Control/Playback.swift create mode 100644 Sources/Control/Volume.swift delete mode 100644 Sources/ControlKit/ControlKit.swift create mode 100644 Sources/Controllers/AppleMusicController.swift create mode 100644 Sources/Controllers/Controllers.swift create mode 100644 Sources/Controllers/Extensions/DAO.swift create mode 100644 Sources/Controllers/PlaybackController.swift create mode 100644 Sources/Controllers/SpotifyController.swift create mode 100644 Sources/Controllers/SystemController.swift create mode 100644 Sources/Controllers/VolumeController.swift diff --git a/Package.swift b/Package.swift index 7408d6d..2255ea9 100644 --- a/Package.swift +++ b/Package.swift @@ -5,14 +5,28 @@ import PackageDescription let package = Package( name: "ControlKit", + platforms: [.iOS(.v15)], products: [ .library( - name: "ControlKit", - targets: ["ControlKit"]), + name: "Controllers", + targets: ["Controllers"]), + .library( + name: "Control", + targets: ["Control"]), + ], + dependencies: [ + .package(url: "https://github.com/spotify/ios-sdk.git", from: "3.0.0") ], targets: [ .target( - name: "ControlKit"), - + name: "Controllers", + dependencies: [ + "Control", + .product(name: "SpotifyiOS", package: "ios-sdk") + ] + ), + .target( + name: "Control" + ) ] ) diff --git a/Sources/Control/Control.swift b/Sources/Control/Control.swift new file mode 100644 index 0000000..e32f155 --- /dev/null +++ b/Sources/Control/Control.swift @@ -0,0 +1,12 @@ +// +// Control.swift +// ControlKit +// + +/// Library namespace. +public enum Control {} + +extension Control { + + static let subsystem = "com.ControlKit.Control" +} diff --git a/Sources/Control/Extensions/BetweenZeroAndOneInclusive.swift b/Sources/Control/Extensions/BetweenZeroAndOneInclusive.swift new file mode 100644 index 0000000..2edc4d1 --- /dev/null +++ b/Sources/Control/Extensions/BetweenZeroAndOneInclusive.swift @@ -0,0 +1,20 @@ +// +// BetweenZeroAndOneInclusive.swift +// ControlKit +// + +@propertyWrapper +struct BetweenZeroAndOneInclusive { + + private var value: Float + private let range: ClosedRange = 0.0...1.0 + + init(wrappedValue: Float) { + value = min(max(wrappedValue, range.lowerBound), range.upperBound) + } + + var wrappedValue: Float { + get { value } + set { value = min(max(newValue, range.lowerBound), range.upperBound) } + } +} diff --git a/Sources/Control/Extensions/MPVolumeView.swift b/Sources/Control/Extensions/MPVolumeView.swift new file mode 100644 index 0000000..9d3169f --- /dev/null +++ b/Sources/Control/Extensions/MPVolumeView.swift @@ -0,0 +1,46 @@ +// +// MPVolumeView.swift +// ControlKit +// + +import MediaPlayer +import OSLog + +extension MPVolumeView { + + static var volume: Float { + get { + shared.slider?.value ?? 0 + } + set { + shared.slider?.value = newValue + } + } + + static func increaseVolume(_ amount: Float) { + guard volume <= 1 else { + log.warning("Volume is already at max") + return + } + volume = min(1, volume + amount) + } + + static func decreaseVolume(_ amount: Float) { + guard volume >= 0 else { + log.warning("Volume is already at min") + return + } + volume = max(0, volume - amount) + } +} + +private extension MPVolumeView { + + static let shared = MPVolumeView() + + static let log = Logger(subsystem: Control.subsystem, category: "MPVolumeView_Extension") + + var slider: UISlider? { + subviews.first(where: { $0 is UISlider }) as? UISlider + } +} diff --git a/Sources/Control/Haptics.swift b/Sources/Control/Haptics.swift new file mode 100644 index 0000000..8e069b5 --- /dev/null +++ b/Sources/Control/Haptics.swift @@ -0,0 +1,17 @@ +// +// Haptics.swift +// ControlKit +// + +import UIKit + +public extension Control { + + @MainActor + enum Haptics { + + public static func vibrate() { + UIImpactFeedbackGenerator().impactOccurred() + } + } +} diff --git a/Sources/Control/Playback.swift b/Sources/Control/Playback.swift new file mode 100644 index 0000000..8aad94c --- /dev/null +++ b/Sources/Control/Playback.swift @@ -0,0 +1,77 @@ +// +// Playback.swift +// ControlKit +// + +import AVFAudio +import MediaPlayer +import OSLog + +public extension Control { + + @MainActor + enum Playback { + + /// Alias for `AVAudioSession.secondaryAudioShouldBeSilencedHint`. + public static var isAudioPlaying: Bool { avAudioSession.secondaryAudioShouldBeSilencedHint } + + private static let avAudioSession = AVAudioSession.sharedInstance() + + /// Toggles system media playback by activating or disactivating the shared `AVAudioSession`. + /// + /// Playback that has been paused by this function can normally be resumed if the app playing the content has not been terminated. + public static func togglePlayPause() { + do { + try avAudioSession.setActive( + isAudioPlaying, + options: .notifyOthersOnDeactivation + ) + } catch { + log.error("\(error.localizedDescription)") + } + } + } +} + +public extension Control.Playback { + + @MainActor + enum AppleMusic { + + /// Subscribe to `isPlaying` via ``AppleMusicController/isPlaying`` `@Published` property. + package static var isPlaying: Bool { systemMusicPlayer.playbackState.isPlaying } + + nonisolated(unsafe) + private static let systemMusicPlayer = MPMusicPlayerController.systemMusicPlayer + + public static func togglePlayPause() { + if systemMusicPlayer.playbackState.isPlaying { + systemMusicPlayer.pause() + } else { + systemMusicPlayer.play() + } + } + + public static func skipToNextTrack() { + systemMusicPlayer.skipToNextItem() + } + + public static func skipToPreviousTrack() { + systemMusicPlayer.skipToPreviousItem() + } + } +} + +private extension Control.Playback { + + static let log = Logger(subsystem: Control.subsystem, category: "Playback") +} + +private extension MPMusicPlaybackState { + + var isPlaying: Bool { + self == .playing + || self == .seekingForward + || self == .seekingBackward + } +} diff --git a/Sources/Control/Volume.swift b/Sources/Control/Volume.swift new file mode 100644 index 0000000..3aeabba --- /dev/null +++ b/Sources/Control/Volume.swift @@ -0,0 +1,79 @@ +// +// Volume.swift +// ControlKit +// + +import MediaPlayer + +public extension Control { + + /// Control the system volume using the underlying `MPVolumeView`. + @MainActor + enum Volume { + + /// Subscribe to `volume` via ``VolumeController/volume`` `@Published` property. + /// + /// - Important: Updating this property will _unmute_ the system volume. The volume level prior to being muted will + /// be ignored when setting the volume via this property. + public static var volume: Float { + get { + MPVolumeView.volume + } + set { + if newValue != 0 && isMuted { + isMuted.toggle() + } + MPVolumeView.volume = newValue + } + } + + /// Subscribe to `isMuted` via ``VolumeController/isMuted`` `@Published` property. + public static var isMuted = false { + didSet { + if isMuted { + Helpers.mutedVolumeLevel = volume + } + volume = isMuted ? 0 : Helpers.mutedVolumeLevel + } + } + + /// Increments the system volume, mimicking when a user taps the volume rocker on their phone. + /// + /// - Parameter amount: clamped between 0 and 1.0 using ``BetweenZeroAndOneInclusive``. + /// + /// - Important: Calling this function will _unmute_ the system volume. The increment amount is + /// applied to the volume level prior to it being muted. + public static func increase( + @BetweenZeroAndOneInclusive _ amount: Float = Helpers.defaultVolumeStep + ) { + volume += amount + } + + /// Decrements the system volume, mimicking when a user taps the volume rocker on their phone. + /// + /// - Parameter amount: clamped between 0 and 1.0 using ``BetweenZeroAndOneInclusive``. + /// + /// - Important: Calling this function will _unmute_ the system volume. The decrement amount is + /// applied to the volume level prior to it being muted. + public static func decrease( + @BetweenZeroAndOneInclusive _ amount: Float = Helpers.defaultVolumeStep + ) { + volume -= amount + } + } +} + +extension Control.Volume { + + public enum Helpers { + + /// Refers to the amount (on scale from 0 to 1) the volume is incremented/decremented when the volume rocker is pressed on the phone. + public static let defaultVolumeStep: Float = 1 / maxVolumeButtonPresses + + /// Refers to the volume level prior to it being muted. + static var mutedVolumeLevel: Float = 0 + + /// Refers to the number of (volume rocker) button presses it takes for the phone's volume to go from 0 to max. + private static let maxVolumeButtonPresses: Float = 16 + } +} diff --git a/Sources/ControlKit/ControlKit.swift b/Sources/ControlKit/ControlKit.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/ControlKit/ControlKit.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/Controllers/AppleMusicController.swift b/Sources/Controllers/AppleMusicController.swift new file mode 100644 index 0000000..6d018b2 --- /dev/null +++ b/Sources/Controllers/AppleMusicController.swift @@ -0,0 +1,35 @@ +// +// AppleMusicController.swift +// ControlKit +// + +import Control +import SwiftUI + +/// Wrapper for ``Control/Playback/AppleMusic`` - use this to control playback from the Apple Music app and subscribe to its state. +public final class AppleMusicController: ObservableObject, PlaybackController { + + @Published public private(set) var isPlaying: Bool = Control.Playback.AppleMusic.isPlaying + + public init() {} + + public func togglePlayPause() { + Control.Playback.AppleMusic.togglePlayPause() + updateIsPlaying() + } + + public func skipToNextTrack() { + Control.Playback.AppleMusic.skipToNextTrack() + } + + public func skipToPreviousTrack() { + Control.Playback.AppleMusic.skipToPreviousTrack() + } +} + +private extension AppleMusicController { + + func updateIsPlaying() { + isPlaying = Control.Playback.AppleMusic.isPlaying + } +} diff --git a/Sources/Controllers/Controllers.swift b/Sources/Controllers/Controllers.swift new file mode 100644 index 0000000..ddd38ce --- /dev/null +++ b/Sources/Controllers/Controllers.swift @@ -0,0 +1,12 @@ +// +// Controllers.swift +// ControlKit +// + +/// Library namespace. +enum Controllers {} + +extension Controllers { + + static let subsystem = "com.ControlKit.Controllers" +} diff --git a/Sources/Controllers/Extensions/DAO.swift b/Sources/Controllers/Extensions/DAO.swift new file mode 100644 index 0000000..c6ed244 --- /dev/null +++ b/Sources/Controllers/Extensions/DAO.swift @@ -0,0 +1,20 @@ +// +// DAO.swift +// ControlKit +// + +import Foundation + +/// Describes a data access object used for persisting an item of generic `Codable` type `DataType` + /// - Parameters: +/// - codingKey: key used to encode + decode persisted object +public protocol DAO: AnyObject { + + associatedtype DataType: Codable + + var codingKey: String { get } + + func get() throws -> DataType + func save(_ value: DataType) throws + func delete() throws +} diff --git a/Sources/Controllers/PlaybackController.swift b/Sources/Controllers/PlaybackController.swift new file mode 100644 index 0000000..4d40c34 --- /dev/null +++ b/Sources/Controllers/PlaybackController.swift @@ -0,0 +1,13 @@ +// +// PlaybackController.swift +// ControlKit +// + +/// Describes an object that controls media/audio playback. +@MainActor +protocol PlaybackController { + + func togglePlayPause() + func skipToNextTrack() + func skipToPreviousTrack() +} diff --git a/Sources/Controllers/SpotifyController.swift b/Sources/Controllers/SpotifyController.swift new file mode 100644 index 0000000..e9c8baa --- /dev/null +++ b/Sources/Controllers/SpotifyController.swift @@ -0,0 +1,214 @@ +// +// SpotifyController.swift +// ControlKit +// + +import OSLog +import SpotifyiOS +import SwiftUI + +/// Wrapper for ``SPTAppRemote`` - use this to control playback and subscribe to the playback state of the Spotify app. +/// +/// `SpotifyController` facilitates the connection to and interaction with the Spotify app, enabling playback control and handling the authorization flow. +/// +public final class SpotifyController: NSObject, ObservableObject { + + @Published public private(set) var isPlaying: Bool = false + + private lazy var remote: SPTAppRemote = { + let remote = SPTAppRemote( + configuration: SPTConfiguration( + clientID: config.clientID, + redirectURL: config.redirectURL + ), + logLevel: config.logLevel + ) + do { + remote.connectionParameters.accessToken = try accessTokenDAO?.get() + } catch { + Self.log.debug("Access token not set: \(error.localizedDescription)") + } + remote.delegate = self + remote.playerAPI?.delegate = self + return remote + }() + + private let config: SpotifyConfig + private let accessTokenDAO: (any DAO)? + + /// The main initializer for `SpotifyController`. + /// + /// - Parameters: + /// - config: The `SpotifyConfig` object used to configure the controller. + /// If `config` is `.empty`, the controller will not attempt to connect automatically. + /// The default log level is **`SPTAppRemoteLogLevel.debug`**. + /// - accessTokenDAO: An object conforming to `DAO` that persists the Spotify access token. By default, this optional parameter + /// is nil and the access token is not persisted across app launches. + /// - autoConnect: A Boolean value that determines whether the controller should attempt to connect to Spotify automatically upon initialization. + /// The default value is `true`. If `true` and `config` is not `.empty`, the controller will initiate the connection. + /// + /// - Warning: Saving the Spotify access token (_or any other sensitive informative)_ using **``UserDefaults``** is not recommended for production apps. + /// Prefer providing a `DAO` that persists values to the Keychain πŸ” + /// + public init( + config: SpotifyConfig, + accessTokenDAO: (any DAO)? = nil, + autoConnect: Bool = true + ) { + self.config = config + self.accessTokenDAO = accessTokenDAO + super.init() + if autoConnect && config != .empty { + connect() + } + } + + /// Deep links to the Spotify app where the authorization flow is completed before reopening the host app. + /// + /// Use `setAccessToken(from url:)` in a SwiftUI `View.onOpenURL(perform:)` modifier to parse and persist the access token. + /// This will be called when the Spotify app completes the authorization flow as it uses the `redirectURL` provided in the configuration + /// to deep link back to the app that initiated the authorization flow. + public func authorize() { + // Using an empty string here will attempt to play the user's last song + self.remote.authorizeAndPlayURI("") + } + + /// Parses the provided URL to extract and assign the access token, optionally persisting it across app launches. + /// + /// This method does three things: + /// - Parses the provided URL + /// - Assigns the access token (if found) to the local `SPTAppRemote.connectionParameters.accessToken` + /// - Attempts to persist the access token using the optional `DAO` property. + /// + /// Use this inside a SwiftUI onOpenURL modifier to parse and save the access token from the URL callback. + /// ``` + /// .onOpenURL { systemController.spotify.setAccessToken(from: $0) } + /// ``` + /// - Important: The access token will **not be persisted** across app launches if an `accessTokenDAO` parameter + /// is not provided when initializing `SpotifyController`. + public func setAccessToken(from url: URL) { + guard + let parameters = remote.authorizationParameters(from: url), + let newToken = parameters[SPTAppRemoteAccessTokenKey] + else { + Self.log.error("Failed to parse access token from URL: \(url, privacy: .private)") + return + } + do { + try accessTokenDAO?.save(newToken) + } catch { + Self.log.warning("Failed to persist access token: \(error.localizedDescription)") + } + remote.connectionParameters.accessToken = newToken + } + + /// Creates a connection (subscription) to the Spotify app. + public func connect() { + guard remote.connectionParameters.accessToken != nil else { + Self.log.warning("Attempting to connect to Spotify without first setting access token") + return + } + remote.connect() + } + + /// Disconnect from the Spotify app. + /// + /// Call this when your app is terminating (scene == .inactive). + public func disconnect() { + guard remote.isConnected else { + Self.log.warning("Attempting to disconnect from Spotify app but remote is not connected") + return + } + remote.disconnect() + do { + try accessTokenDAO?.delete() + } catch { + Self.log.warning("Failed to delete access token: \(error.localizedDescription)") + } + } +} + +extension SpotifyController: PlaybackController { + + public func togglePlayPause() { + remote.togglePlayPause() + } + + public func skipToNextTrack() { + remote.playerAPI?.skip(toNext: nil) + } + + public func skipToPreviousTrack() { + remote.playerAPI?.skip(toPrevious: nil) + } +} + +extension SpotifyController: SPTAppRemoteDelegate { + + public func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) { + SpotifyController.log.info("SPTAppRemoteDelegate.appRemoteDidEstablishConnection") + } + + public func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: (any Error)?) { + SpotifyController.log.info("SPTAppRemoteDelegate.didFailConnectionAttemptWithError") + } + + public func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: (any Error)?) { + SpotifyController.log.info("SPTAppRemoteDelegate.didDisconnectWithError") + } +} + +extension SpotifyController: SPTAppRemotePlayerStateDelegate { + + public func playerStateDidChange(_ playerState: any SPTAppRemotePlayerState) { + isPlaying = !playerState.isPaused + } +} + +extension SpotifyController { + + public struct SpotifyConfig: Equatable { + + let clientID: String + let logLevel: SPTAppRemoteLogLevel + let redirectURL: URL + + public init( + clientID: String, + logLevel: SPTAppRemoteLogLevel = .info, + redirectURL: String + ) { + self.clientID = clientID + self.logLevel = logLevel + self.redirectURL = URL(string: redirectURL)! + } + + public static var empty: Self { + .init(clientID: "123", redirectURL: "456") + } + } + + private static let log = Logger(subsystem: Controllers.subsystem, category: "SpotifyController") +} + +private extension SPTAppRemote { + + func togglePlayPause() { + playerAPI?.getPlayerState { [weak self] result, error in + guard + let self, + error == nil, + let state = result as? SPTAppRemotePlayerState + else { + Logger(subsystem: Controllers.subsystem, category: "SPTAppRemote_Extension") + .error("Failed to get player state with error: \(error?.localizedDescription ?? "πŸ€·β€β™‚οΈ")") + return + } + if state.isPaused { + playerAPI?.resume(nil) + } else { + playerAPI?.pause(nil) + } + } + } +} diff --git a/Sources/Controllers/SystemController.swift b/Sources/Controllers/SystemController.swift new file mode 100644 index 0000000..27d51ec --- /dev/null +++ b/Sources/Controllers/SystemController.swift @@ -0,0 +1,98 @@ +// +// SystemController.swift +// ControlKit +// + +import Control +import SwiftUI + +/// Controller of controllers πŸ‘‘ +@MainActor +public final class SystemController: ObservableObject { + + public enum PlaybackType { case appleMusic, spotify, avAudio } + + @Published public var selectedPlaybackType: PlaybackType = .avAudio + + @Published public private(set) var isAudioPlaying = false + @Published public private(set) var isMuted = false + + @Published public private(set) var appleMusic = AppleMusicController() + @Published public private(set) var spotify: SpotifyController! + @Published public private(set) var volume = VolumeController() + + public init(spotifyConfig: SpotifyController.SpotifyConfig = .empty) { + spotify = SpotifyController(config: spotifyConfig) + } +} + +extension SystemController: PlaybackController { + + /// Toggles play-pause according to the `selectedPlaybackType`, mimicking the playback controls in Control Center. + public func togglePlayPause() { + switch selectedPlaybackType { + + case .appleMusic: + appleMusic.togglePlayPause() + case .spotify: + spotify.togglePlayPause() + case .avAudio: + Control.Playback.togglePlayPause() + } + + updateIsAudioPlaying() + } + + /// Skips to the next track according to the `selectedPlaybackType`, mimicking the playback controls in Control Center. + /// + /// - Note: When `avAudio` is the `selectedPlaybackType` this method will attempt to skip to the next track for **both + /// the Apple Music and Spotify apps**. It's not possible to skip to the next track for apps besides Apple Music and Spotify. + public func skipToNextTrack() { + switch selectedPlaybackType { + + case .appleMusic: + appleMusic.skipToNextTrack() + case .spotify: + spotify.skipToNextTrack() + case .avAudio: + appleMusic.skipToNextTrack() + spotify.skipToNextTrack() + } + } + + /// Skips to the previous track according to the `selectedPlaybackType`, mimicking the playback controls in Control Center. + /// + /// - Note: When `avAudio` is the `selectedPlaybackType` this method will attempt to skip to the previous track for **both + /// the Apple Music and Spotify apps**. It's not possible to skip to the previous track for apps besides Apple Music and Spotify. + public func skipToPreviousTrack() { + switch selectedPlaybackType { + + case .appleMusic: + appleMusic.skipToPreviousTrack() + case .spotify: + spotify.skipToPreviousTrack() + case .avAudio: + appleMusic.skipToPreviousTrack() + spotify.skipToPreviousTrack() + } + } + + private func updateIsAudioPlaying() { + switch selectedPlaybackType { + + case .appleMusic: + isAudioPlaying = appleMusic.isPlaying + case .spotify: + isAudioPlaying = spotify.isPlaying + case .avAudio: + isAudioPlaying = Control.Playback.isAudioPlaying + } + } +} + +extension SystemController { + + public func vibrate() { + Control.Haptics.vibrate() + } +} diff --git a/Sources/Controllers/VolumeController.swift b/Sources/Controllers/VolumeController.swift new file mode 100644 index 0000000..6a2e3c1 --- /dev/null +++ b/Sources/Controllers/VolumeController.swift @@ -0,0 +1,43 @@ +// +// VolumeController.swift +// ControlKit +// + +import Control +import SwiftUI + +/// A controller for managing audio volume and mute state. +/// +/// `VolumeController` provides functionality to control the audio volume and mute state of the device. +/// +@MainActor +public final class VolumeController: ObservableObject { + + @Published public private(set) var volume: Float = Control.Volume.volume + @Published public private(set) var isMuted = Control.Volume.isMuted + + public init() {} + + public func increaseVolume(_ amount: Float = 0.1) { + Control.Volume.increase(amount) + volumeChanged() + } + + public func decreaseVolume(_ amount: Float = 0.1) { + Control.Volume.decrease(amount) + volumeChanged() + } + + public func toggleMute() { + Control.Volume.isMuted.toggle() + volumeChanged() + } +} + +private extension VolumeController { + + func volumeChanged() { + volume = Control.Volume.volume + isMuted = Control.Volume.isMuted + } +} From aadc745ccde7f1e71aabc9485dcecb38f34daccb Mon Sep 17 00:00:00 2001 From: Ryan F Date: Wed, 30 Oct 2024 20:56:31 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=9D=20Package:=20Add=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b36dc6 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +![ControlKit Light](https://github.com/user-attachments/assets/11ad00d7-a200-46bc-bc99-4b214131dfe7#gh-light-mode-only) +![ControlKit Dark](https://github.com/user-attachments/assets/b0d1fa79-4b56-4fd1-bb5b-4a25c9e3254c#gh-dark-mode-only) + +# ControlKit + +**ControlKit** is a minimal Swift Package enabling control of media playback and system volume. + +![Minimum iOS Version](https://img.shields.io/badge/%F0%9F%93%B1%20iOS-15%2B-blue.svg) ![Build Status](https://github.com/superturboryan/ControlKit/workflows/%F0%9F%A7%A9%20Build%20Package/badge.svg) ![Lint](https://github.com/superturboryan/ControlKit/workflows/%F0%9F%A7%B9%20Lint/badge.svg) ![Contributors](https://img.shields.io/github/contributors/superturboryan/ci-playground) + +### TLDR + +Control the device's volume with one line: + +```swift +Control.Volume.increase() // πŸ”Š πŸ†™ +``` + +## Installation + +Add ControlKit as a dependency in your Xcode project: + +1. Go to **File > Add Package Dependencies…** + +2. Enter the package URL in the search bar: + +``` +https://github.com/superturboryan/ControlKit.git +``` + +3. Choose the libraries you want to include: + +![Screenshot 2024-10-28 at 17 46 50](https://github.com/user-attachments/assets/48e0a678-fd75-4056-9754-867a11b87d67) + +## Requirements + +[`SpotifyController`](Sources/Controllers/SpotifyController.swift) requires a Spotify _Client ID_ +and _Redirect URL_ to authorize with & control the Spotify app. + +1. [Define a custom URL scheme for your app](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). +Add a `URL Type` to the target's `Info.plist`. + +2. Create an app in the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) +to get a _client ID_ and register your _redirect URL_ (scheme). + +Screenshot 2024-10-29 at 17 31 24 + +### Warning πŸ‘‡ + +The Spotify access token is **not persisted across app launches** by default. + +You must provide an object conforming to **`DAO`** if you want the access token to be persisted. + +## Usage + +### Control + +```swift +import Control + +// πŸ”Š Decrement system volume +Control.Volume.decreaseVolume() + +// πŸ•΅οΈβ€β™‚οΈ Check if audio is being played (by another app) +if Control.Playback.isAudioPlaying { + // πŸ’ƒπŸ•Ί +} + +// ⏭️ Skip to next track (Apple Music only - use Controllers.SpotifyController for Spotify πŸ’š) +Control.Playback.AppleMusic.skipToNextTrack() + +// 🫨 Vibrate +Control.Haptics.vibrate() +``` + +### Controllers + +```swift +// App.swift + +import Controllers +import SwiftUI + +@main struct YourApp: App { + + @StateObject var spotify = SpotifyController( + config: .init( + clientID: Secrets.clientID, + redirectURL: "controlkit://spotify" + ) + ) + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(spotify) + .onOpenURL { spotify.setAccessToken(from: $0) } // Parse access token from URL + } + } + + func skipToNextSpotifyTrack() { + spotify.skipToNextTrack() + } +} + +// Secrets.swift πŸ” +// Don't forget to gitignore this πŸ™ˆ + +enum Secrets { + static let clientID = "" +} +``` + +## Dependencies + +πŸ“š [AVFAudio](https://developer.apple.com/documentation/avfaudio) +πŸ“š [Media Player](https://developer.apple.com/documentation/mediaplayer/) +πŸ“¦ [SpotifyiOS](https://github.com/spotify/ios-sdk) + +## Contributing + +Contributions and feedback are welcome! πŸ§‘β€πŸ’»πŸ‘©β€πŸ’» + +Here are a few guidelines: + +- You can [open an Issue](https://github.com/superturboryan/ControlKit/issues/new) or raise a PR 🀝 +- Commit messages should contain emojis ❀️ and be [signed](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) πŸ” +- [Workflows](https://github.com/superturboryan/ControlKit/actions) should be green 🟒 +- `main` should be [linear](https://stackoverflow.com/questions/20348629/what-are-the-advantages-of-keeping-linear-history-in-git) πŸŽ‹ From c4b856586a0b36208f19a8325c068cc27712fc86 Mon Sep 17 00:00:00 2001 From: Ryan F Date: Wed, 30 Oct 2024 20:57:08 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=E2=9A=96=EF=B8=8F=20P?= =?UTF-8?q?ackage:=20Add=20MIT=20license?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..56f642e --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright 2024 Ryan Forsyth + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the β€œSoftware”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 5a4d98590ae7094ce9f9316c48ee1c945864b184 Mon Sep 17 00:00:00 2001 From: Ryan F Date: Fri, 1 Nov 2024 20:43:09 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=A4=96=20CI:=20Add=20lint=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lints code using SwiftLint --- .github/workflows/lint.yml | 44 ++++++++++++ .gitignore | 3 +- .swiftlint.yml | 71 +++++++++++++++++++ .../check_latest_commit_for_skip.py | 32 +++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .swiftlint.yml create mode 100644 workflow_scripts/check_latest_commit_for_skip.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..6540150 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,44 @@ +name: 🧹 Lint + +on: + pull_request: + branches: [ "main" ] + types: [opened, synchronize] + + # push: + # Every push if left empty + +jobs: + + lint-code: + runs-on: macos-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Check last commit for skip keyword + run: python workflow_scripts/check_latest_commit_for_skip.py >> $GITHUB_ENV + + - name: ⏩ SKIPPING REMAINING STEPS πŸ‘€ + if: env.should_skip == 'true' + run: exit 0 + + - name: Set up Ruby + if: env.should_skip == 'false' + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.5' # latest stable as of 2 November 2024 + + - name: Install SwiftLint + if: env.should_skip == 'false' + run: brew install swiftlint + + - name: Lint code using SwiftLint + if: env.should_skip == 'false' + run: swiftlint --strict \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6e6ede7..11d020d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ xcuserdata/ *.xcuserdatad *.xcbkptlist *.xcsettings +*.xcscheme *.xcworkspacedata *.DS_Store -*SpotifyConfig.swift +*.build diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..4e30d7b --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,71 @@ +included: + - Sources + +line_length: + warning: 110 + error: 150 + ignores_comments: true + +disabled_rules: + - trailing_whitespace + - trailing_comma + - void_function_in_ternary + +opt_in_rules: + - array_init + - attributes + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - discouraged_object_literal + - empty_count + - empty_string + - empty_xctest_method + - explicit_init + - fallthrough + - file_header + - file_name + - first_where + - flatmap_over_map_reduce + - identical_operands + - joined_default_parameter + - legacy_random + - let_var_whitespace + - last_where + - literal_expression_end_indentation + - lower_acl_than_parent + - modifier_order + - nimble_operator + - nslocalizedstring_key + - number_separator + - object_literal + - operator_usage_whitespace + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - xct_specific_matcher + - yoda_condition diff --git a/workflow_scripts/check_latest_commit_for_skip.py b/workflow_scripts/check_latest_commit_for_skip.py new file mode 100644 index 0000000..73ce2e4 --- /dev/null +++ b/workflow_scripts/check_latest_commit_for_skip.py @@ -0,0 +1,32 @@ +import subprocess +import sys + +# Define the keyword that should skip the workflow +SKIP_KEYWORDS = ["wip", "skip-ci", "no-build"] + +# Function to get the latest commit message +def get_latest_commit_message(): + try: + # Use subprocess to run the git command and get the output + commit_message = subprocess.check_output( + ["git", "log", "-1", "--pretty=%B"], + text=True + ).strip() + return commit_message + except subprocess.CalledProcessError as e: + print(f"Error getting commit message: {e}") + sys.exit(1) + +# Function to check if the commit message contains any skip keyword +def contains_skip_keyword(commit_message, keywords): + return any(keyword in commit_message for keyword in keywords) + +# Main logic +if __name__ == "__main__": + commit_message = get_latest_commit_message() + + # Output values that can be used in the workflow environment + if contains_skip_keyword(commit_message, SKIP_KEYWORDS): + print(f"should_skip={True}") + else: + print(f"should_skip={False}") From 0719c1dfb9b90a1aac1fca94485092bafc04c798 Mon Sep 17 00:00:00 2001 From: Ryan F Date: Fri, 1 Nov 2024 21:14:10 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A4=96=20CI:=20Add=20build-package=20?= =?UTF-8?q?workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decrement `swift-tools-version` from 6.0 to 5.10.0 - Can be set to 6.0 again when swift-actions/setup-swift supports Swift 6 - See: https://github.com/swift-actions/setup-swift/pull/684 --- .github/workflows/build-package.yml | 49 +++++++++++++++++++++++++++++ Package.swift | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-package.yml diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml new file mode 100644 index 0000000..49b8686 --- /dev/null +++ b/.github/workflows/build-package.yml @@ -0,0 +1,49 @@ +name: 🧩 Build Package + +on: + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: macos-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Check last commit for skip keyword + run: python workflow_scripts/check_latest_commit_for_skip.py >> $GITHUB_ENV + + - name: ⏩ SKIPPING REMAINING STEPS πŸ‘€ + if: env.should_skip == 'true' + run: exit 0 + + - name: Setup Swift + if: env.should_skip == 'false' + uses: swift-actions/setup-swift@v2.0.0 + with: + swift-version: '5.10' # Should match swift-tools-version in Package.swift + + - name: Build Control Library + if: env.should_skip == 'false' + run: | + xcodebuild -scheme Control \ + -sdk iphonesimulator \ + -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \ + BUILD_DIR=$(pwd)/build/Control + + - name: Build Controllers Library + if: env.should_skip == 'false' + run: | + xcodebuild -scheme Controllers \ + -sdk iphonesimulator \ + -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \ + BUILD_DIR=$(pwd)/build/Controllers diff --git a/Package.swift b/Package.swift index 2255ea9..15ae145 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 5.10.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription