Skip to content

Commit d78f2d5

Browse files
🚀 Package: add Control + Controllers libraries
- Set minimum iOS version to 15
1 parent 44d13b5 commit d78f2d5

15 files changed

+704
-6
lines changed

Package.swift

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,28 @@ import PackageDescription
55

66
let package = Package(
77
name: "ControlKit",
8+
platforms: [.iOS(.v15)],
89
products: [
910
.library(
10-
name: "ControlKit",
11-
targets: ["ControlKit"]),
11+
name: "Controllers",
12+
targets: ["Controllers"]),
13+
.library(
14+
name: "Control",
15+
targets: ["Control"]),
16+
],
17+
dependencies: [
18+
.package(url: "https://github.com/spotify/ios-sdk.git", from: "3.0.0")
1219
],
1320
targets: [
1421
.target(
15-
name: "ControlKit"),
16-
22+
name: "Controllers",
23+
dependencies: [
24+
"Control",
25+
.product(name: "SpotifyiOS", package: "ios-sdk")
26+
]
27+
),
28+
.target(
29+
name: "Control"
30+
)
1731
]
1832
)

Sources/Control/Control.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// Control.swift
3+
// ControlKit
4+
//
5+
6+
/// Library namespace.
7+
public enum Control {}
8+
9+
extension Control {
10+
11+
static let subsystem = "com.ControlKit.Control"
12+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// BetweenZeroAndOneInclusive.swift
3+
// ControlKit
4+
//
5+
6+
@propertyWrapper
7+
struct BetweenZeroAndOneInclusive {
8+
9+
private var value: Float
10+
private let range: ClosedRange<Float> = 0.0...1.0
11+
12+
init(wrappedValue: Float) {
13+
value = min(max(wrappedValue, range.lowerBound), range.upperBound)
14+
}
15+
16+
var wrappedValue: Float {
17+
get { value }
18+
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
19+
}
20+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// MPVolumeView.swift
3+
// ControlKit
4+
//
5+
6+
import MediaPlayer
7+
import OSLog
8+
9+
extension MPVolumeView {
10+
11+
static var volume: Float {
12+
get {
13+
shared.slider?.value ?? 0
14+
}
15+
set {
16+
shared.slider?.value = newValue
17+
}
18+
}
19+
20+
static func increaseVolume(_ amount: Float) {
21+
guard volume <= 1 else {
22+
log.warning("Volume is already at max")
23+
return
24+
}
25+
volume = min(1, volume + amount)
26+
}
27+
28+
static func decreaseVolume(_ amount: Float) {
29+
guard volume >= 0 else {
30+
log.warning("Volume is already at min")
31+
return
32+
}
33+
volume = max(0, volume - amount)
34+
}
35+
}
36+
37+
private extension MPVolumeView {
38+
39+
static let shared = MPVolumeView()
40+
41+
static let log = Logger(subsystem: Control.subsystem, category: "MPVolumeView_Extension")
42+
43+
var slider: UISlider? {
44+
subviews.first(where: { $0 is UISlider }) as? UISlider
45+
}
46+
}

Sources/Control/Haptics.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// Haptics.swift
3+
// ControlKit
4+
//
5+
6+
import UIKit
7+
8+
public extension Control {
9+
10+
@MainActor
11+
enum Haptics {
12+
13+
public static func vibrate() {
14+
UIImpactFeedbackGenerator().impactOccurred()
15+
}
16+
}
17+
}

Sources/Control/Playback.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// Playback.swift
3+
// ControlKit
4+
//
5+
6+
import AVFAudio
7+
import MediaPlayer
8+
import OSLog
9+
10+
public extension Control {
11+
12+
@MainActor
13+
enum Playback {
14+
15+
/// Alias for `AVAudioSession.secondaryAudioShouldBeSilencedHint`.
16+
public static var isAudioPlaying: Bool { avAudioSession.secondaryAudioShouldBeSilencedHint }
17+
18+
private static let avAudioSession = AVAudioSession.sharedInstance()
19+
20+
/// Toggles system media playback by activating or disactivating the shared `AVAudioSession`.
21+
///
22+
/// Playback that has been paused by this function can normally be resumed if the app playing the content has not been terminated.
23+
public static func togglePlayPause() {
24+
do {
25+
try avAudioSession.setActive(
26+
isAudioPlaying,
27+
options: .notifyOthersOnDeactivation
28+
)
29+
} catch {
30+
log.error("\(error.localizedDescription)")
31+
}
32+
}
33+
}
34+
}
35+
36+
public extension Control.Playback {
37+
38+
@MainActor
39+
enum AppleMusic {
40+
41+
/// Subscribe to `isPlaying` via ``AppleMusicController/isPlaying`` `@Published` property.
42+
package static var isPlaying: Bool { systemMusicPlayer.playbackState.isPlaying }
43+
44+
nonisolated(unsafe)
45+
private static let systemMusicPlayer = MPMusicPlayerController.systemMusicPlayer
46+
47+
public static func togglePlayPause() {
48+
if systemMusicPlayer.playbackState.isPlaying {
49+
systemMusicPlayer.pause()
50+
} else {
51+
systemMusicPlayer.play()
52+
}
53+
}
54+
55+
public static func skipToNextTrack() {
56+
systemMusicPlayer.skipToNextItem()
57+
}
58+
59+
public static func skipToPreviousTrack() {
60+
systemMusicPlayer.skipToPreviousItem()
61+
}
62+
}
63+
}
64+
65+
private extension Control.Playback {
66+
67+
static let log = Logger(subsystem: Control.subsystem, category: "Playback")
68+
}
69+
70+
private extension MPMusicPlaybackState {
71+
72+
var isPlaying: Bool {
73+
self == .playing
74+
|| self == .seekingForward
75+
|| self == .seekingBackward
76+
}
77+
}

Sources/Control/Volume.swift

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// Volume.swift
3+
// ControlKit
4+
//
5+
6+
import MediaPlayer
7+
8+
public extension Control {
9+
10+
/// Control the system volume using the underlying `MPVolumeView`.
11+
@MainActor
12+
enum Volume {
13+
14+
/// Subscribe to `volume` via ``VolumeController/volume`` `@Published` property.
15+
///
16+
/// - Important: Updating this property will _unmute_ the system volume. The volume level prior to being muted will
17+
/// be ignored when setting the volume via this property.
18+
public static var volume: Float {
19+
get {
20+
MPVolumeView.volume
21+
}
22+
set {
23+
if newValue != 0 && isMuted {
24+
isMuted.toggle()
25+
}
26+
MPVolumeView.volume = newValue
27+
}
28+
}
29+
30+
/// Subscribe to `isMuted` via ``VolumeController/isMuted`` `@Published` property.
31+
public static var isMuted = false {
32+
didSet {
33+
if isMuted {
34+
Helpers.mutedVolumeLevel = volume
35+
}
36+
volume = isMuted ? 0 : Helpers.mutedVolumeLevel
37+
}
38+
}
39+
40+
/// Increments the system volume, mimicking when a user taps the volume rocker on their phone.
41+
///
42+
/// - Parameter amount: clamped between 0 and 1.0 using ``BetweenZeroAndOneInclusive``.
43+
///
44+
/// - Important: Calling this function will _unmute_ the system volume. The increment amount is
45+
/// applied to the volume level prior to it being muted.
46+
public static func increase(
47+
@BetweenZeroAndOneInclusive _ amount: Float = Helpers.defaultVolumeStep
48+
) {
49+
volume += amount
50+
}
51+
52+
/// Decrements the system volume, mimicking when a user taps the volume rocker on their phone.
53+
///
54+
/// - Parameter amount: clamped between 0 and 1.0 using ``BetweenZeroAndOneInclusive``.
55+
///
56+
/// - Important: Calling this function will _unmute_ the system volume. The decrement amount is
57+
/// applied to the volume level prior to it being muted.
58+
public static func decrease(
59+
@BetweenZeroAndOneInclusive _ amount: Float = Helpers.defaultVolumeStep
60+
) {
61+
volume -= amount
62+
}
63+
}
64+
}
65+
66+
extension Control.Volume {
67+
68+
public enum Helpers {
69+
70+
/// Refers to the amount (on scale from 0 to 1) the volume is incremented/decremented when the volume rocker is pressed on the phone.
71+
public static let defaultVolumeStep: Float = 1 / maxVolumeButtonPresses
72+
73+
/// Refers to the volume level prior to it being muted.
74+
static var mutedVolumeLevel: Float = 0
75+
76+
/// Refers to the number of (volume rocker) button presses it takes for the phone's volume to go from 0 to max.
77+
private static let maxVolumeButtonPresses: Float = 16
78+
}
79+
}

Sources/ControlKit/ControlKit.swift

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// AppleMusicController.swift
3+
// ControlKit
4+
//
5+
6+
import Control
7+
import SwiftUI
8+
9+
/// Wrapper for ``Control/Playback/AppleMusic`` - use this to control playback from the Apple Music app and subscribe to its state.
10+
public final class AppleMusicController: ObservableObject, PlaybackController {
11+
12+
@Published public private(set) var isPlaying: Bool = Control.Playback.AppleMusic.isPlaying
13+
14+
public init() {}
15+
16+
public func togglePlayPause() {
17+
Control.Playback.AppleMusic.togglePlayPause()
18+
updateIsPlaying()
19+
}
20+
21+
public func skipToNextTrack() {
22+
Control.Playback.AppleMusic.skipToNextTrack()
23+
}
24+
25+
public func skipToPreviousTrack() {
26+
Control.Playback.AppleMusic.skipToPreviousTrack()
27+
}
28+
}
29+
30+
private extension AppleMusicController {
31+
32+
func updateIsPlaying() {
33+
isPlaying = Control.Playback.AppleMusic.isPlaying
34+
}
35+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// Controllers.swift
3+
// ControlKit
4+
//
5+
6+
/// Library namespace.
7+
enum Controllers {}
8+
9+
extension Controllers {
10+
11+
static let subsystem = "com.ControlKit.Controllers"
12+
}

0 commit comments

Comments
 (0)