Custom player slider

dev
wenlei 10 months ago
parent 9355fa5d15
commit 5146380302

@ -16,6 +16,9 @@
770228ED2B55284F00E07F7A /* Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228EC2B55284F00E07F7A /* Login.swift */; }; 770228ED2B55284F00E07F7A /* Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228EC2B55284F00E07F7A /* Login.swift */; };
770228EF2B56142E00E07F7A /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228EE2B56142E00E07F7A /* User.swift */; }; 770228EF2B56142E00E07F7A /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228EE2B56142E00E07F7A /* User.swift */; };
770228F12B57AD2C00E07F7A /* RefreshLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228F02B57AD2C00E07F7A /* RefreshLoadingView.swift */; }; 770228F12B57AD2C00E07F7A /* RefreshLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228F02B57AD2C00E07F7A /* RefreshLoadingView.swift */; };
770228F72B5A224500E07F7A /* SliderControlValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228F42B5A224500E07F7A /* SliderControlValuePublisher.swift */; };
770228F82B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228F52B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift */; };
770228F92B5A224500E07F7A /* SliderControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228F62B5A224500E07F7A /* SliderControl.swift */; };
77165D742B464493002AE0A5 /* BarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77165D732B464493002AE0A5 /* BarButtonItem.swift */; }; 77165D742B464493002AE0A5 /* BarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77165D732B464493002AE0A5 /* BarButtonItem.swift */; };
7736FF442B4CECF2008D5DAD /* CommentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736FF432B4CECF2008D5DAD /* CommentViewModel.swift */; }; 7736FF442B4CECF2008D5DAD /* CommentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736FF432B4CECF2008D5DAD /* CommentViewModel.swift */; };
7736FF462B4CF0E6008D5DAD /* CommentDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736FF452B4CF0E6008D5DAD /* CommentDetailViewController.swift */; }; 7736FF462B4CF0E6008D5DAD /* CommentDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736FF452B4CF0E6008D5DAD /* CommentDetailViewController.swift */; };
@ -218,6 +221,9 @@
770228EC2B55284F00E07F7A /* Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = "<group>"; }; 770228EC2B55284F00E07F7A /* Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = "<group>"; };
770228EE2B56142E00E07F7A /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; }; 770228EE2B56142E00E07F7A /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
770228F02B57AD2C00E07F7A /* RefreshLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshLoadingView.swift; sourceTree = "<group>"; }; 770228F02B57AD2C00E07F7A /* RefreshLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshLoadingView.swift; sourceTree = "<group>"; };
770228F42B5A224500E07F7A /* SliderControlValuePublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderControlValuePublisher.swift; sourceTree = "<group>"; };
770228F52B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Multiplier.swift"; sourceTree = "<group>"; };
770228F62B5A224500E07F7A /* SliderControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderControl.swift; sourceTree = "<group>"; };
77165D732B464493002AE0A5 /* BarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarButtonItem.swift; sourceTree = "<group>"; }; 77165D732B464493002AE0A5 /* BarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarButtonItem.swift; sourceTree = "<group>"; };
7736FF432B4CECF2008D5DAD /* CommentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewModel.swift; sourceTree = "<group>"; }; 7736FF432B4CECF2008D5DAD /* CommentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewModel.swift; sourceTree = "<group>"; };
7736FF452B4CF0E6008D5DAD /* CommentDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailViewController.swift; sourceTree = "<group>"; }; 7736FF452B4CF0E6008D5DAD /* CommentDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailViewController.swift; sourceTree = "<group>"; };
@ -434,6 +440,24 @@
path = RxActivityIndicator; path = RxActivityIndicator;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
770228F22B5A224500E07F7A /* SliderControl */ = {
isa = PBXGroup;
children = (
770228F32B5A224500E07F7A /* Combine */,
770228F52B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift */,
770228F62B5A224500E07F7A /* SliderControl.swift */,
);
path = SliderControl;
sourceTree = "<group>";
};
770228F32B5A224500E07F7A /* Combine */ = {
isa = PBXGroup;
children = (
770228F42B5A224500E07F7A /* SliderControlValuePublisher.swift */,
);
path = Combine;
sourceTree = "<group>";
};
7743999C2AFA18B0006F8EEA /* Player */ = { 7743999C2AFA18B0006F8EEA /* Player */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -672,6 +696,7 @@
778B8A5A2AF8EAFD0034AFD4 /* Third Party */ = { 778B8A5A2AF8EAFD0034AFD4 /* Third Party */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
770228F22B5A224500E07F7A /* SliderControl */,
770228E32B54FA3600E07F7A /* RxActivityIndicator */, 770228E32B54FA3600E07F7A /* RxActivityIndicator */,
778B8A602AF8ECC20034AFD4 /* RxErrorTracker */, 778B8A602AF8ECC20034AFD4 /* RxErrorTracker */,
7751D3812B45324300F1F2BD /* IndieMusic-Bridging-Header.h */, 7751D3812B45324300F1F2BD /* IndieMusic-Bridging-Header.h */,
@ -748,10 +773,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
778B8A772AF8ECE50034AFD4 /* HomeTabBarViewModel.swift */, 778B8A772AF8ECE50034AFD4 /* HomeTabBarViewModel.swift */,
778B8A7A2AF8ECE50034AFD4 /* HomeTabBarController.swift */,
778B8A782AF8ECE50034AFD4 /* HomeViewModel.swift */, 778B8A782AF8ECE50034AFD4 /* HomeViewModel.swift */,
778B8A792AF8ECE50034AFD4 /* HomeViewController.swift */, 778B8A792AF8ECE50034AFD4 /* HomeViewController.swift */,
774A18062B06045200F56DF1 /* HomeView.swift */, 774A18062B06045200F56DF1 /* HomeView.swift */,
778B8A7A2AF8ECE50034AFD4 /* HomeTabBarController.swift */,
774A180F2B070A6900F56DF1 /* SongViewCell.swift */, 774A180F2B070A6900F56DF1 /* SongViewCell.swift */,
774A18112B07327C00F56DF1 /* CommentCountButton.swift */, 774A18112B07327C00F56DF1 /* CommentCountButton.swift */,
774A18132B07329600F56DF1 /* MultiUserAvatarView.swift */, 774A18132B07329600F56DF1 /* MultiUserAvatarView.swift */,
@ -1148,6 +1173,7 @@
7751D3782B43EA1200F1F2BD /* SearchResultsController.swift in Sources */, 7751D3782B43EA1200F1F2BD /* SearchResultsController.swift in Sources */,
7751D35A2B42B5BD00F1F2BD /* EditInfoView.swift in Sources */, 7751D35A2B42B5BD00F1F2BD /* EditInfoView.swift in Sources */,
7751D35E2B42B61100F1F2BD /* PersonInfo.swift in Sources */, 7751D35E2B42B61100F1F2BD /* PersonInfo.swift in Sources */,
770228F72B5A224500E07F7A /* SliderControlValuePublisher.swift in Sources */,
774399A82AFE28BA006F8EEA /* BlurEffectView.swift in Sources */, 774399A82AFE28BA006F8EEA /* BlurEffectView.swift in Sources */,
778B8A852AF8ECE50034AFD4 /* SearchViewController.swift in Sources */, 778B8A852AF8ECE50034AFD4 /* SearchViewController.swift in Sources */,
770228E92B55169C00E07F7A /* InternationalNumber.swift in Sources */, 770228E92B55169C00E07F7A /* InternationalNumber.swift in Sources */,
@ -1201,6 +1227,7 @@
778B8ABB2AF8ED280034AFD4 /* WebViewController.swift in Sources */, 778B8ABB2AF8ED280034AFD4 /* WebViewController.swift in Sources */,
774A17F72B04932100F56DF1 /* SegmentControl.swift in Sources */, 774A17F72B04932100F56DF1 /* SegmentControl.swift in Sources */,
77FA0B282B0B3E1E00404C5E /* Journal.swift in Sources */, 77FA0B282B0B3E1E00404C5E /* Journal.swift in Sources */,
770228F82B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift in Sources */,
77FA0B5A2B147EC900404C5E /* CommentViewController.swift in Sources */, 77FA0B5A2B147EC900404C5E /* CommentViewController.swift in Sources */,
77FA0B402B0D8E9300404C5E /* LayoutableButton.swift in Sources */, 77FA0B402B0D8E9300404C5E /* LayoutableButton.swift in Sources */,
77C9B9C22B4AB6C10006C83F /* AboutViewModel.swift in Sources */, 77C9B9C22B4AB6C10006C83F /* AboutViewModel.swift in Sources */,
@ -1255,6 +1282,7 @@
7751D37C2B4516BE00F1F2BD /* UILabel+IndieMusic.swift in Sources */, 7751D37C2B4516BE00F1F2BD /* UILabel+IndieMusic.swift in Sources */,
778B8AAC2AF8ED0E0034AFD4 /* UIViewController+Rx.swift in Sources */, 778B8AAC2AF8ED0E0034AFD4 /* UIViewController+Rx.swift in Sources */,
778B8A8D2AF8ECF20034AFD4 /* APIImage.swift in Sources */, 778B8A8D2AF8ECF20034AFD4 /* APIImage.swift in Sources */,
770228F92B5A224500E07F7A /* SliderControl.swift in Sources */,
7751D36A2B42ED6C00F1F2BD /* TimingViewController.swift in Sources */, 7751D36A2B42ED6C00F1F2BD /* TimingViewController.swift in Sources */,
77FA0B462B0DFAFD00404C5E /* BindPhoneViewController.swift in Sources */, 77FA0B462B0DFAFD00404C5E /* BindPhoneViewController.swift in Sources */,
77FA0B382B0C54C700404C5E /* FilterViewController.swift in Sources */, 77FA0B382B0C54C700404C5E /* FilterViewController.swift in Sources */,

@ -9,12 +9,14 @@ import Foundation
import AVFoundation import AVFoundation
extension AVPlayer { extension AVPlayer {
func addProgressObserver(completion: @escaping ((Double) -> Void)) { func addProgressObserver(completion: @escaping ((Double, Double, Double) -> Void)) {
self.addPeriodicTimeObserver(forInterval: CMTime.init(value: 1, timescale: 10), queue: .main, using: { time in self.addPeriodicTimeObserver(forInterval: CMTime.init(value: 1, timescale: 10), queue: .main, using: { time in
if let duration = self.currentItem?.duration { if let duration = self.currentItem?.duration {
let duration = CMTimeGetSeconds(duration), time = CMTimeGetSeconds(time) let duration = CMTimeGetSeconds(duration), time = CMTimeGetSeconds(time)
let progress = (time/duration) let progress = (time/duration)
completion(progress)
print("1媒体文件总时长: \(duration)")
completion(time, duration, progress)
} }
}) })
} }

@ -15,6 +15,13 @@ extension Notification.Name {
/// ///
static let notiPlayAudioTrack = Notification.Name("notiPlayAudioTrack") static let notiPlayAudioTrack = Notification.Name("notiPlayAudioTrack")
///
static let notiPlayPause = Notification.Name("notiPlayPause")
///
static let notiPlayResume = Notification.Name("notiPlayResume")
///
static let notiPlayShuffle = Notification.Name("notiPlayShuffle")
} }

@ -15,9 +15,6 @@ class AudioManager {
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
init() { init() {
NotificationCenter.default NotificationCenter.default
.addObserver(self, .addObserver(self,
@ -62,6 +59,13 @@ class AudioManager {
var repeatActive = false var repeatActive = false
var shuffleActive = false var shuffleActive = false
var playerShuffleType: PlayerShuffleType = .sequential
var isSeekInProgress = false
var chasingTime = CMTime.zero
func setPlaylist(list: [AudioTrack]) { func setPlaylist(list: [AudioTrack]) {
self.playlist = list self.playlist = list
} }
@ -154,12 +158,47 @@ class AudioManager {
public func pause() { public func pause() {
if let player = player { if let player = player {
player.pause() player.pause()
NotificationCenter.default.post(name: .notiPlayPause, object: nil)
} }
} }
public func resume() { public func resume() {
if let player = player { if let player = player {
player.play() player.play()
NotificationCenter.default.post(name: .notiPlayResume, object: nil)
}
}
func changeShuffle() {
self.playerShuffleType = self.playerShuffleType.changeShuffle()
NotificationCenter.default.post(name: .notiPlayShuffle, object: nil)
}
func seekActually(time: CMTime) {
isSeekInProgress = true
player?.seek(to: time, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { success in
if time == self.chasingTime {
self.isSeekInProgress = false
} else {
self.trySeekToChaseTime()
}
}
}
func trySeekToChaseTime() {
guard let status = player?.currentItem?.status, status != .readyToPlay else { return }
seekActually(time: chasingTime)
}
func seekSmoothly(to time: CMTime) {
if chasingTime != time {
chasingTime = time
if !isSeekInProgress {
trySeekToChaseTime()
}
} }
} }

@ -8,6 +8,58 @@
import Foundation import Foundation
import RxDataSources import RxDataSources
enum PlayerShuffleType {
case random
case sequential
case singleLoop
case listLoop
}
extension PlayerShuffleType {
var description: String {
switch self {
case .random:
return "随机"
case .sequential:
return "顺序"
case .singleLoop:
return "单曲循环"
case .listLoop:
return "列表循环"
}
}
var image: String {
switch self {
case .random:
return "play_shuffle_random"
case .sequential:
return "play_shuffle_singleLoop"
case .singleLoop:
return "play_shuffle_singleLoop"
case .listLoop:
return "play_shuffle_singleLoop"
}
}
func changeShuffle() -> PlayerShuffleType {
switch self {
case .sequential:
return .random
case .random:
return .singleLoop
case .singleLoop:
return .listLoop
case .listLoop:
return .sequential
}
}
}
struct PlayerLyrics: Codable { struct PlayerLyrics: Codable {
let lyrics: String? let lyrics: String?
} }

@ -160,7 +160,10 @@ class HomeTabBarController: UITabBarController, Navigatable {
func bindViewModel() { func bindViewModel() {
guard let viewModel = viewModel else { return } guard let viewModel = viewModel else { return }
let input = HomeTabBarViewModel.Input.init() let input = HomeTabBarViewModel.Input.init(notiPlayAudioTrack: NotificationCenter.default.rx.notification(.notiPlayAudioTrack),
notiPlayPause: NotificationCenter.default.rx.notification(.notiPlayPause),
notiPlayResume: NotificationCenter.default.rx.notification(.notiPlayResume)
)
let output = viewModel.transform(input: input) let output = viewModel.transform(input: input)
output.tabBarItems.delay(.milliseconds(50)).drive(onNext: { [weak self] (tabBarItems) in output.tabBarItems.delay(.milliseconds(50)).drive(onNext: { [weak self] (tabBarItems) in
@ -172,13 +175,11 @@ class HomeTabBarController: UITabBarController, Navigatable {
playerTabBar.playButton.rx.tap.subscribe { _ in playerTabBar.playButton.rx.tap.subscribe { _ in
if AudioManager.sharedInstance.isPlaying() {
// let audioTrack = AudioTrack.init(album: nil, AudioManager.sharedInstance.pause()
// artists: [], availableMarkets: nil, discNumber: 1, durationMs: 2, explicit: false, externalUrls: ["1": ""], id: "111", name: "222", previewUrl: "http://downsc.chinaz.net/Files/DownLoad/sound1/201906/11582.mp3") } else {
AudioManager.sharedInstance.resume()
}
// AudioManager.sharedInstance.playTrack(track: audioTrack)
}.disposed(by: rx.disposeBag) }.disposed(by: rx.disposeBag)
@ -199,6 +200,21 @@ class HomeTabBarController: UITabBarController, Navigatable {
}.disposed(by: rx.disposeBag) }.disposed(by: rx.disposeBag)
output.notiPlayAudioTrack.subscribe { [weak self] noti in
self?.playerTabBar.audioTrack = AudioManager.sharedInstance.currentTrack
}.disposed(by: rx.disposeBag)
output.notiPlayResume.subscribe { [weak self] noti in
self?.playerTabBar.playButton.isSelected = true
}.disposed(by: rx.disposeBag)
output.notiPlayPause.subscribe { [weak self] noti in
self?.playerTabBar.playButton.isSelected = false
}.disposed(by: rx.disposeBag)
} }

@ -13,11 +13,19 @@ import SVProgressHUD
class HomeTabBarViewModel: ViewModel, ViewModelType { class HomeTabBarViewModel: ViewModel, ViewModelType {
struct Input { struct Input {
let notiPlayAudioTrack: Observable<Notification>
let notiPlayPause: Observable<Notification>
let notiPlayResume: Observable<Notification>
} }
struct Output { struct Output {
let tabBarItems: Driver<[HomeTabBarItem]> let tabBarItems: Driver<[HomeTabBarItem]>
let notiPlayAudioTrack: Observable<Notification>
let notiPlayPause: Observable<Notification>
let notiPlayResume: Observable<Notification>
} }
let authorized: Bool let authorized: Bool
@ -39,7 +47,10 @@ class HomeTabBarViewModel: ViewModel, ViewModelType {
return Output(tabBarItems: tabBarItems) return Output.init(tabBarItems: tabBarItems,
notiPlayAudioTrack: input.notiPlayAudioTrack,
notiPlayPause: input.notiPlayPause,
notiPlayResume: input.notiPlayResume)
} }
func viewModel(for tabBarItem: HomeTabBarItem) -> ViewModel { func viewModel(for tabBarItem: HomeTabBarItem) -> ViewModel {

@ -78,9 +78,6 @@ class SongViewCell: UITableViewCell {
if AudioManager.sharedInstance.currentTrack?.id == audioTrack.id { if AudioManager.sharedInstance.currentTrack?.id == audioTrack.id {
musicIndicator.isHidden = false musicIndicator.isHidden = false
musicIndicator.state = AudioManager.sharedInstance.isPlaying() ? .playing : .paused musicIndicator.state = AudioManager.sharedInstance.isPlaying() ? .playing : .paused
} else { } else {
musicIndicator.isHidden = true musicIndicator.isHidden = true
} }

@ -58,7 +58,8 @@ class AudioTrackListViewController: ViewController {
guard let viewModel = viewModel as? AudioTrackListViewModel else { return } guard let viewModel = viewModel as? AudioTrackListViewModel else { return }
let input = AudioTrackListViewModel.Input.init(viewWillAppear: rx.viewWillAppear, let input = AudioTrackListViewModel.Input.init(viewWillAppear: rx.viewWillAppear,
selection: tableView.rx.itemSelected.asDriver()) selection: tableView.rx.itemSelected.asDriver(),
notiPlayAudioTrack: NotificationCenter.default.rx.notification(.notiPlayAudioTrack))
let output = viewModel.transform(input: input) let output = viewModel.transform(input: input)
let dataSource = AudioTrackListViewController.dataSource { [weak self] cell, audioTrack in let dataSource = AudioTrackListViewController.dataSource { [weak self] cell, audioTrack in
@ -78,6 +79,22 @@ class AudioTrackListViewController: ViewController {
}.disposed(by: rx.disposeBag) }.disposed(by: rx.disposeBag)
output.reloadData.subscribe { audioTrack in
self.tableView.reloadData()
}.disposed(by: rx.disposeBag)
self.audioMoreActionBottomView.closeButton.rx.tap.subscribe { _ in
self.navigator.dismiss(sender: self)
}.disposed(by: rx.disposeBag)
self.tableView.mj_header = nil self.tableView.mj_header = nil
self.tableView.mj_footer = nil self.tableView.mj_footer = nil

@ -16,18 +16,21 @@ class AudioTrackListViewModel: ViewModel, ViewModelType {
struct Input { struct Input {
let viewWillAppear: ControlEvent<Bool> let viewWillAppear: ControlEvent<Bool>
let selection: Driver<IndexPath> let selection: Driver<IndexPath>
let notiPlayAudioTrack: Observable<Notification>
} }
struct Output { struct Output {
let items: BehaviorRelay<[JournalSection]> let items: BehaviorRelay<[JournalSection]>
let itemSelected: PublishSubject<MusicStyle> let itemSelected: PublishSubject<MusicStyle>
let reloadData: PublishRelay<AudioTrack>
} }
let items = BehaviorRelay<[JournalSection]>.init(value: []) let items = BehaviorRelay<[JournalSection]>.init(value: [])
let itemSelected = PublishSubject<MusicStyle>() let itemSelected = PublishSubject<MusicStyle>()
let reloadData = PublishRelay<AudioTrack>()
func transform(input: Input) -> Output { func transform(input: Input) -> Output {
@ -57,8 +60,19 @@ class AudioTrackListViewModel: ViewModel, ViewModelType {
input.notiPlayAudioTrack.subscribe { noti in
guard let track = noti.element?.object as? AudioTrack else { return }
self.reloadData.accept(track)
}.disposed(by: rx.disposeBag)
return Output.init(items: items, return Output.init(items: items,
itemSelected: itemSelected) itemSelected: itemSelected,
reloadData: reloadData)
} }
} }

@ -6,6 +6,7 @@
// //
import UIKit import UIKit
import MarqueeLabel
class PlayerTabBar: UIControl { class PlayerTabBar: UIControl {
private var coverView: UIImageView = { private var coverView: UIImageView = {
@ -22,18 +23,25 @@ class PlayerTabBar: UIControl {
}() }()
private var titleLabel: UILabel = { private var titleLabel: UILabel = {
var titleLabel = UILabel.init() let titleLabel = MarqueeLabel.init(frame: .zero, rate: 15, fadeLength: 10)
titleLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium)
titleLabel.textColor = .primaryText()
return titleLabel return titleLabel
}() }()
private var artistLabel: UILabel = { private var artistLabel: MarqueeLabel = {
var artistLabel = UILabel.init() let artistLabel = MarqueeLabel.init(frame: .zero, rate: 15, fadeLength: 10)
artistLabel.font = UIFont.systemFont(ofSize: 12)
artistLabel.textColor = .tertiaryText()
return artistLabel return artistLabel
}() }()
var playButton: UIButton = { var playButton: UIButton = {
var playButton = UIButton.init() var playButton = UIButton.init()
playButton.setImage(UIImage.init(named: "playerBar_play_btn"), for: .normal) playButton.setImage(UIImage.init(named: "playerBar_play_btn"), for: .normal)
playButton.setImage(UIImage.init(named: "playerBar_play_btn"), for: .selected)
return playButton return playButton
}() }()
@ -50,8 +58,17 @@ class PlayerTabBar: UIControl {
}() }()
var audioTrack: AudioTrack? {
didSet {
guard let audioTrack = audioTrack else { return }
titleLabel.text = audioTrack.title
artistLabel.text = (audioTrack.artist ?? "") + "/" + (audioTrack.album ?? "")
coverView.kf.setImage(with: URL.init(string: audioTrack.pic ?? ""))
}
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -133,6 +150,71 @@ class PlayerTabBar: UIControl {
} }
func setPlayIcon() {
// self.playButton.image = UIImage(named: "play")
}
func setPauseIcon() {
// self.playButton.image = UIImage(named: "pause")
}
// @objc func playTap(_ sender: UITapGestureRecognizer) {
// delegate?.playTap()
// }
//
// @objc func showPlayerView(_ sender: UITapGestureRecognizer) {
// delegate?.tap()
// }
@objc func playTrackNotification(_ notification: NSNotification) {
guard let track = notification.object as? AudioTrack else { return }
playTrack(track: track)
}
func hidePlayerBar() {
isHidden = true
}
func showPlayerBar() {
isHidden = false
}
func playTrack(track: AudioTrack) {
showPlayerBar()
self.audioTrack = track
// nameLabel.text = track.name
// artistLabel.text = track.artists.first?.name
// if let url = track.album?.images.first?.url {
// ImageManager.sharedInstance.getImageFromURL(url: URL(string: url)!) { result in
// switch result {
// case .success(let image):
// self.imageSong.image = image
// if let color = image.averageColor?.darker(by: 30) {
// self.customView.backgroundColor = color
// }
// case .failure:
// break
// }
// }
// }
AudioManager.sharedInstance.player?.addProgressObserver { time, duration, progress in
self.progressView.setProgress(Float(progress), animated: false)
}
// self.playButton.image = UIImage(named: "pause")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
super.hitTest(point, with: event) super.hitTest(point, with: event)

@ -8,6 +8,7 @@
import UIKit import UIKit
import SVProgressHUD import SVProgressHUD
import MarqueeLabel import MarqueeLabel
import AVFoundation
class PlayerViewTopBar: UIView { class PlayerViewTopBar: UIView {
lazy var dropButton: UIButton = { lazy var dropButton: UIButton = {
@ -211,7 +212,6 @@ class PlayerScrollView: UIScrollView {
playerInfoView.numberLabel.text = "VOL \(audioTrack.journalNo ?? "")" playerInfoView.numberLabel.text = "VOL \(audioTrack.journalNo ?? "")"
playerInfoView.updateData(audioTrack: audioTrack) playerInfoView.updateData(audioTrack: audioTrack)
SVProgressHUD.showText(withStatus: audioTrack.title)
print("audioTrack \(audioTrack)") print("audioTrack \(audioTrack)")
} }
@ -273,6 +273,28 @@ class PlayerScrollView: UIScrollView {
setContentOffset(offset, animated: animated) setContentOffset(offset, animated: animated)
} }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
super.hitTest(point, with: event)
if self.isHidden {
return super.hitTest(point, with: event)
}
let sliderPoint = convert(point, to: playerInfoView.playerSlider)
if playerInfoView.playerSlider.point(inside: sliderPoint, with: event) {
return playerInfoView.playerSlider.slider
}
if self.bounds.contains(point) {
return self
}
return super.hitTest(point, with: event)
}
} }
extension PlayerScrollView: UIScrollViewDelegate { extension PlayerScrollView: UIScrollViewDelegate {
@ -517,30 +539,14 @@ class PlayerInfoView: UIView {
return likeButton return likeButton
}() }()
var playerSlider: UISlider = { var playerSlider: PlayerSlider = {
let playerSlider = UISlider.init() let playerSlider = PlayerSlider.init()
playerSlider.tintColor = .white
return playerSlider return playerSlider
}() }()
var startTimeLabel: UILabel = {
let startTimeLabel = UILabel.init()
startTimeLabel.font = UIFont.systemFont(ofSize: 12)
startTimeLabel.textColor = .init(hex: 0xFFFFFF, alpha: 0.4)
return startTimeLabel
}()
var endTimeLabel: UILabel = {
let endTimeLabel = UILabel.init()
endTimeLabel.font = UIFont.systemFont(ofSize: 12)
endTimeLabel.textColor = .init(hex: 0xFFFFFF, alpha: 0.4)
return endTimeLabel
}()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -554,6 +560,7 @@ class PlayerInfoView: UIView {
// endTimeLabel.text = "00:00" // endTimeLabel.text = "00:00"
// //
// coverView.backgroundColor = .gray // coverView.backgroundColor = .gray
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -570,8 +577,6 @@ class PlayerInfoView: UIView {
addSubview(shareButton) addSubview(shareButton)
addSubview(likeButton) addSubview(likeButton)
addSubview(playerSlider) addSubview(playerSlider)
addSubview(startTimeLabel)
addSubview(endTimeLabel)
} }
@ -630,22 +635,155 @@ class PlayerInfoView: UIView {
playerSlider.snp.makeConstraints { make in playerSlider.snp.makeConstraints { make in
make.left.equalTo(self).offset(18) make.left.equalTo(self).offset(18)
make.right.equalTo(self).offset(-18) make.right.equalTo(self).offset(-18)
make.top.equalTo(artistLabel.snp.bottom).offset(18) make.top.equalTo(artistLabel.snp.bottom).offset(8)
make.height.equalTo(30)
make.bottom.equalTo(self)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
super.hitTest(point, with: event)
let sliderPoint = convert(point, to: playerSlider)
if playerSlider.point(inside: sliderPoint, with: event) {
return playerSlider
}
if self.bounds.contains(point) {
return self
}
return super.hitTest(point, with: event)
}
}
class PlayerSlider: UIView {
let slider: UISlider = {
let slider = UISlider.init()
slider.tintColor = .white
slider.setThumbImage(UIImage.init(named: "play_ thumb_icon"), for: .normal)
return slider
}()
var startTimeLabel: UILabel = {
let startTimeLabel = UILabel.init()
startTimeLabel.font = UIFont.systemFont(ofSize: 12)
startTimeLabel.textColor = .init(hex: 0xFFFFFF, alpha: 0.4)
startTimeLabel.text = "--:--"
return startTimeLabel
}()
var endTimeLabel: UILabel = {
let endTimeLabel = UILabel.init()
endTimeLabel.font = UIFont.systemFont(ofSize: 12)
endTimeLabel.textColor = .init(hex: 0xFFFFFF, alpha: 0.4)
endTimeLabel.text = "--:--"
return endTimeLabel
}()
var isDrop = false
var totalSec: Float = 0
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
slider.addTarget(self, action: #selector(sliderSliderValueChange), for: .valueChanged)
slider.addTarget(self, action: #selector(sliderSliderTouchDown), for: .touchDown)
slider.addTarget(self, action: #selector(sliderSliderTouchUpInside), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func makeUI() {
addSubview(slider)
addSubview(startTimeLabel)
addSubview(endTimeLabel)
}
override func layoutSubviews() {
super.layoutSubviews()
slider.snp.makeConstraints { make in
make.left.equalTo(self)
make.right.equalTo(self)
make.top.equalTo(self).offset(10)
} }
startTimeLabel.snp.makeConstraints { make in startTimeLabel.snp.makeConstraints { make in
make.left.equalTo(self).offset(18) make.left.equalTo(self)
make.top.equalTo(playerSlider.snp.bottom).offset(3) make.top.equalTo(slider.snp.bottom).offset(10)
make.bottom.equalTo(self).offset(-10) make.bottom.equalTo(self).offset(-10)
} }
endTimeLabel.snp.makeConstraints { make in endTimeLabel.snp.makeConstraints { make in
make.right.equalTo(self).offset(-18) make.right.equalTo(self)
make.top.equalTo(playerSlider.snp.bottom).offset(3) make.top.equalTo(slider.snp.bottom).offset(10)
make.bottom.equalTo(self).offset(-10) make.bottom.equalTo(self).offset(-10)
} }
} }
@objc func sliderSliderValueChange() {
//
let currentTime = self.totalSec * slider.value
let currentMin = currentTime / 60
let currentSec = currentTime.truncatingRemainder(dividingBy: 60)
let totalMin = self.totalSec / 60
let totalSec = self.totalSec.truncatingRemainder(dividingBy: 60)
self.startTimeLabel.text = String.init(format: "%02.f:%02.f", currentMin, currentSec)
self.endTimeLabel.text = String.init(format: "%02.f:%02.f", totalMin, totalSec)
}
@objc func sliderSliderTouchDown() {
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.isDrop = true
// }
}
@objc func sliderSliderTouchUpInside() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.isDrop = false
} }
let time = CMTime.init(seconds: Double(slider.value * self.totalSec), preferredTimescale: 1)
AudioManager.sharedInstance.seekActually(time: time)
}
func updateTimeLabel(currentTime: Float) {
let currentMin = currentTime / 60
let currentSec = currentTime.truncatingRemainder(dividingBy: 60)
let totalMin = self.totalSec / 60
let totalSec = self.totalSec.truncatingRemainder(dividingBy: 60)
self.startTimeLabel.text = String.init(format: "%02.f:%02.f", currentMin, currentSec)
self.endTimeLabel.text = String.init(format: "%02.f:%02.f", totalMin, totalSec)
}
}

@ -97,7 +97,8 @@ class PlayerViewController: ViewController {
nextButtonTrigger: playerControlView.nextButton.rx.tap.asDriver(), nextButtonTrigger: playerControlView.nextButton.rx.tap.asDriver(),
playButtonTrigger: playerControlView.playButton.rx.tap.asDriver(), playButtonTrigger: playerControlView.playButton.rx.tap.asDriver(),
moreButtonTrigger: playerViewTopBar.moreButton.rx.tap.asDriver(), moreButtonTrigger: playerViewTopBar.moreButton.rx.tap.asDriver(),
notiPlayAudioTrack: NotificationCenter.default.rx.notification(.notiPlayAudioTrack)) notiPlayAudioTrack: NotificationCenter.default.rx.notification(.notiPlayAudioTrack),
notiPlayShuffle: NotificationCenter.default.rx.notification(.notiPlayShuffle))
let output = viewModel.transform(input: input) let output = viewModel.transform(input: input)
@ -118,7 +119,12 @@ class PlayerViewController: ViewController {
viewModel.isLike.subscribe { [weak self] isLike in viewModel.isLike.subscribe { [weak self] isLike in
self?.playerScrollView.playerInfoView.likeButton.isSelected = isLike self?.playerScrollView.playerInfoView.likeButton.isSelected = isLike
}.disposed(by: rx.disposeBag)
output.toShare.subscribe { [weak self] _ in
let share = ShareActionViewModel.init(provider: viewModel.provider)
self?.navigator.show(segue: .share(viewModel: share), sender: self, transition: .navigationPresent(type: .share))
}.disposed(by: rx.disposeBag) }.disposed(by: rx.disposeBag)
@ -139,6 +145,40 @@ class PlayerViewController: ViewController {
}.disposed(by: rx.disposeBag) }.disposed(by: rx.disposeBag)
output.playShuffleType.subscribe { playerShuffleType in
guard let playerShuffleType = playerShuffleType.element else { return }
self.playerControlView.shuffleButton.setImage(UIImage.init(named: playerShuffleType.image), for: .normal)
print("playerShuffleType: \(playerShuffleType) \(playerShuffleType.image)")
}.disposed(by: rx.disposeBag)
output.progress.subscribe { progress in
if !self.playerScrollView.playerInfoView.playerSlider.isDrop {
self.playerScrollView.playerInfoView.playerSlider.slider.value = progress
}
print("progress \(self.playerScrollView.playerInfoView.playerSlider.isDrop) \(progress) \(self.playerScrollView.playerInfoView.playerSlider.isFocused)")
}.disposed(by: rx.disposeBag)
output.duration.subscribe { duration in
self.playerScrollView.playerInfoView.playerSlider.totalSec = duration
}.disposed(by: rx.disposeBag)
output.time.subscribe { time in
self.playerScrollView.playerInfoView.playerSlider.updateTimeLabel(currentTime: time)
}.disposed(by: rx.disposeBag)
self.playerControlView.listButton.rx.tap.subscribe { _ in self.playerControlView.listButton.rx.tap.subscribe { _ in

@ -8,6 +8,7 @@
import Foundation import Foundation
import RxSwift import RxSwift
import RxCocoa import RxCocoa
import RxOptional
class PlayerViewModel: ViewModel, ViewModelType { class PlayerViewModel: ViewModel, ViewModelType {
@ -22,16 +23,23 @@ class PlayerViewModel: ViewModel, ViewModelType {
let playButtonTrigger: Driver<Void> let playButtonTrigger: Driver<Void>
let moreButtonTrigger: Driver<Void> let moreButtonTrigger: Driver<Void>
let notiPlayAudioTrack: Observable<Notification> let notiPlayAudioTrack: Observable<Notification>
let notiPlayShuffle: Observable<Notification>
} }
struct Output { struct Output {
let items: BehaviorRelay<[PlayerLyricsSection]> let items: BehaviorRelay<[PlayerLyricsSection]>
let isLike: BehaviorRelay<Bool> // let isLike: BehaviorRelay<Bool>
let shuffle: BehaviorRelay<Bool> let shuffle: BehaviorRelay<Bool>
let audioTrack: PublishSubject<AudioTrack> let audioTrack: PublishSubject<AudioTrack>
let toShare: BehaviorRelay<Void> let toShare: PublishSubject<Void>
let toList: BehaviorRelay<Void> let toList: BehaviorRelay<Void>
let playShuffleType: BehaviorRelay<PlayerShuffleType>
let progress: BehaviorRelay<Float>
let duration: BehaviorRelay<Float>
let time: BehaviorRelay<Float>
} }
let items = BehaviorRelay<[PlayerLyricsSection]>.init(value: []) let items = BehaviorRelay<[PlayerLyricsSection]>.init(value: [])
@ -41,9 +49,16 @@ class PlayerViewModel: ViewModel, ViewModelType {
let isPlaying = BehaviorRelay<Bool>.init(value: false) let isPlaying = BehaviorRelay<Bool>.init(value: false)
let shuffle = BehaviorRelay<Bool>.init(value: false) let shuffle = BehaviorRelay<Bool>.init(value: false)
let audioTrack = PublishSubject<AudioTrack>.init() let audioTrack = PublishSubject<AudioTrack>.init()
let toShare = BehaviorRelay<Void>.init(value: ()) let toShare = PublishSubject<Void>.init()
let toList = BehaviorRelay<Void>.init(value: ()) let toList = BehaviorRelay<Void>.init(value: ())
let playShuffleType = BehaviorRelay<PlayerShuffleType>.init(value: .sequential)
let progress = BehaviorRelay<Float>.init(value: 0)
let duration = BehaviorRelay<Float>.init(value: 0)
let time = BehaviorRelay<Float>.init(value: 0)
var trackINfo: AudioTrack? var trackINfo: AudioTrack?
init(track: AudioTrack?, provider: IndieMusicAPI) { init(track: AudioTrack?, provider: IndieMusicAPI) {
@ -52,39 +67,40 @@ class PlayerViewModel: ViewModel, ViewModelType {
print("audioTrack333 ") print("audioTrack333 ")
self.trackINfo = track self.trackINfo = track
self.audioTrack.onNext(track) self.audioTrack.onNext(track)
// if let duration = AudioManager.sharedInstance..currentItem?.duration {
// let duration = CMTimeGetSeconds(duration), time = CMTimeGetSeconds(time)
// let progress = (time/duration)
// completion(progress)
// }
} }
func transform(input: Input) -> Output { func transform(input: Input) -> Output {
let lyrics = PlayerLyrics.init(lyrics: "1233211232") input.likeButtonTrigger.drive { [weak self] _ in
let section = PlayerLyricsSection.init(items: [lyrics, lyrics, lyrics, lyrics, lyrics, lyrics, lyrics]) guard let self = self else { return }
self.isLike.accept(!self.isLike.value)
items.accept([section]) }.disposed(by: rx.disposeBag)
let lyrics = PlayerLyrics.init(lyrics: "1233211232")
let section = PlayerLyricsSection.init(items: [lyrics, lyrics, lyrics, lyrics, lyrics, lyrics, lyrics])
items.accept([section])
input.playButtonTrigger.drive(onNext: { _ in
// let album = Album.init(albumType: <#T##String#>,
// availableMarkets: <#T##[String]?#>,
// id: <#T##String#>,
// images: <#T##[APIImage]#>,
// name: <#T##String#>,
// releaseDate: <#T##String#>,
// totalTracks: <#T##Int#>,
// artists: <#T##[Artist]#>)
// input.shareButtonTrigger.drive { _ in
// let audioTrack = AudioTrack.init(album: nil, self.toShare.onNext(())
// artists: [], availableMarkets: nil, discNumber: 1, durationMs: 2, explicit: false, externalUrls: ["1": ""], id: "111", name: "222", previewUrl: "http://downsc.chinaz.net/Files/DownLoad/sound1/201906/11582.mp3") }.disposed(by: rx.disposeBag)
// AudioManager.sharedInstance.setPlaylist(list: [audioTrack])
// AudioManager.sharedInstance.playTrack(track: audioTrack)
input.playButtonTrigger.drive(onNext: { _ in
if AudioManager.sharedInstance.isPlaying() { if AudioManager.sharedInstance.isPlaying() {
AudioManager.sharedInstance.pause() AudioManager.sharedInstance.pause()
} else { } else {
@ -117,15 +133,66 @@ class PlayerViewModel: ViewModel, ViewModelType {
guard let track = noti.element?.object as? AudioTrack else { return } guard let track = noti.element?.object as? AudioTrack else { return }
self?.audioTrack.onNext(track) self?.audioTrack.onNext(track)
AudioManager.sharedInstance.player?.addProgressObserver { currentTime, duration, progress in
self?.progress.accept(Float(progress))
self?.duration.accept(Float(duration))
self?.time.accept(Float(currentTime))
print("viewmodel addProgressObserver : \(progress)")
}
}.disposed(by: rx.disposeBag)
input.shuffleButtonTrigger.drive { _ in
AudioManager.sharedInstance.changeShuffle()
}.disposed(by: rx.disposeBag)
input.notiPlayShuffle.subscribe { _ in
self.playShuffleType.accept(AudioManager.sharedInstance.playerShuffleType)
}.disposed(by: rx.disposeBag) }.disposed(by: rx.disposeBag)
return Output.init(items: items, return Output.init(items: items,
isLike: isLike, // isLike: isLike,
shuffle: shuffle, shuffle: shuffle,
audioTrack: audioTrack, audioTrack: audioTrack,
toShare: toShare, toShare: toShare,
toList: toList) toList: toList,
playShuffleType: playShuffleType,
progress: progress,
duration: duration,
time: time)
}
} }
extension PlayerViewModel {
func playTrack(track: AudioTrack) {
self.audioTrack.onNext(track)
// if let url = track.album?.images.first?.url {
// ImageManager.sharedInstance.getImageFromURL(url: URL(string: url)!) { result in
// switch result {
// case .success(let image):
// self.coverImage.image = image
// if let color = self.coverImage.image?.averageColor, let color2 = color.darker(by: 20) {
// self.gradientBackground.colors = [color.cgColor, color2.cgColor]
// }
// case .failure:
// break
// }
// }
// }
AudioManager.sharedInstance.player?.addProgressObserver { time, duration, progress in
self.progress.accept(Float(progress))
}
} }
}

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "play_ thumb_icon.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,3 @@
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="5" cy="5" r="5" fill="white" style="fill:white;fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 181 B

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "player_shuffle_random.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5303 2.46967C20.6022 2.54158 20.6565 2.62445 20.6931 2.71291C20.7298 2.80134 20.75 2.89831 20.75 3V8C20.75 8.41421 20.4142 8.75 20 8.75C19.5858 8.75 19.25 8.41421 19.25 8V4.81066L3.53033 20.5303C3.23744 20.8232 2.76256 20.8232 2.46967 20.5303C2.17678 20.2374 2.17678 19.7626 2.46967 19.4697L18.1893 3.75H15C14.5858 3.75 14.25 3.41421 14.25 3C14.25 2.58579 14.5858 2.25 15 2.25H20C20.1919 2.25 20.3839 2.32322 20.5303 2.46967ZM20.75 21V16C20.75 15.5858 20.4142 15.25 20 15.25C19.5858 15.25 19.25 15.5858 19.25 16V19.1893L14.5303 14.4697C14.2374 14.1768 13.7626 14.1768 13.4697 14.4697C13.1768 14.7626 13.1768 15.2374 13.4697 15.5303L18.1893 20.25H15C14.5858 20.25 14.25 20.5858 14.25 21C14.25 21.4142 14.5858 21.75 15 21.75H20C20.0082 21.75 20.0163 21.7499 20.0244 21.7496C20.0291 21.7495 20.0339 21.7493 20.0386 21.749C20.1262 21.7446 20.2099 21.7251 20.2871 21.6931C20.3755 21.6565 20.4584 21.6022 20.5303 21.5303C20.6022 21.4584 20.6565 21.3755 20.6931 21.2871C20.7298 21.1987 20.75 21.1017 20.75 21ZM3.53033 3.46967C3.23744 3.17678 2.76256 3.17678 2.46967 3.46967C2.17678 3.76256 2.17678 4.23744 2.46967 4.53033L7.46967 9.53033C7.76256 9.82322 8.23744 9.82322 8.53033 9.53033C8.82322 9.23744 8.82322 8.76256 8.53033 8.46967L3.53033 3.46967Z" fill="white" fill-opacity="0.7" style="fill:white;fill-opacity:0.7;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "player_shuffle_singleLoop.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2536 2.04621C17.9754 1.76796 17.5242 1.76796 17.246 2.04621C16.9677 2.32446 16.9677 2.77559 17.246 3.05384L18.8297 4.63753H7.2498C6.05302 4.63753 4.90524 5.11295 4.05899 5.95921C3.21273 6.80547 2.7373 7.95324 2.7373 9.15003V11.05C2.7373 11.4435 3.0563 11.7625 3.4498 11.7625C3.84331 11.7625 4.1623 11.4435 4.1623 11.05V9.15003C4.1623 8.33117 4.48759 7.54585 5.06661 6.96684C5.64563 6.38782 6.43095 6.06253 7.2498 6.06253H18.8297L17.246 7.64621C16.9677 7.92446 16.9677 8.37559 17.246 8.65384C17.5242 8.93209 17.9754 8.93209 18.2536 8.65384L21.0508 5.85662C21.0607 5.84689 21.0702 5.83688 21.0795 5.82659C21.1313 5.76904 21.1722 5.70507 21.202 5.63742C21.2408 5.54951 21.2623 5.45228 21.2623 5.35003C21.2623 5.2488 21.2412 5.15251 21.2031 5.06531C21.1721 4.99409 21.1289 4.92691 21.0735 4.86693C21.0663 4.85914 21.059 4.85152 21.0515 4.84406L18.2536 2.04621ZM20.5498 12.2375C20.9433 12.2375 21.2623 12.5565 21.2623 12.95V14.85C21.2623 16.0468 20.7869 17.1946 19.9406 18.0408C19.0944 18.8871 17.9466 19.3625 16.7498 19.3625H5.16992L6.75362 20.9462C7.03187 21.2245 7.03187 21.6756 6.75362 21.9538C6.47537 22.2321 6.02424 22.2321 5.74599 21.9538L2.94599 19.1538C2.87659 19.0844 2.8245 19.0043 2.78972 18.9187C2.75593 18.8358 2.7373 18.7451 2.7373 18.65C2.7373 18.5519 2.75715 18.4584 2.79304 18.3733C2.82481 18.2978 2.87016 18.2267 2.9291 18.1637C2.93529 18.1571 2.94161 18.1505 2.94805 18.1442L5.74599 15.3462C6.02424 15.068 6.47537 15.068 6.75362 15.3462C7.03187 15.6245 7.03187 16.0756 6.75362 16.3538L5.16994 17.9375H16.7498C17.5687 17.9375 18.354 17.6122 18.933 17.0332C19.512 16.4542 19.8373 15.6689 19.8373 14.85V12.95C19.8373 12.5565 20.1563 12.2375 20.5498 12.2375ZM13.2498 9.00002C13.2498 8.75115 13.1264 8.51849 12.9203 8.37897C12.7142 8.23945 12.4524 8.21123 12.2213 8.30366L9.72129 9.30365C9.3367 9.45749 9.14964 9.89396 9.30347 10.2786C9.45731 10.6631 9.89379 10.8502 10.2784 10.6964L11.7498 10.1078V15.5C11.7498 15.9142 12.0856 16.25 12.4998 16.25C12.914 16.25 13.2498 15.9142 13.2498 15.5V9.00002Z" fill="white" fill-opacity="0.7" style="fill:white;fill-opacity:0.7;"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -0,0 +1,56 @@
//
// SliderControl.swift
// SliderControl
//
// Created by Alexander Chekel on 19.09.2023.
// Copyright © 2023 Alexander Chekel. All rights reserved.
//
import Combine
import UIKit
public class SliderControlValueSubscription<S: Subscriber>: Subscription where S.Input == Float {
private let subscriber: S
private weak var control: SliderControl?
public init(subscriber: S, control: SliderControl) {
self.subscriber = subscriber
self.control = control
self.control?.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
}
public func request(_ demand: Subscribers.Demand) {
// Cannot request more .valueChanged events
}
public func cancel() {
control?.removeTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
control = nil
}
@objc private func sliderValueChanged(sender: SliderControl) {
_ = subscriber.receive(sender.value)
}
}
public struct SliderControlValuePublisher: Publisher {
public typealias Output = Float
public typealias Failure = Never
private weak var control: SliderControl?
public init(control: SliderControl) {
self.control = control
}
public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == Never, S.Input == Float {
guard let control else {
subscriber.receive(completion: .finished)
return
}
let subscription = SliderControlValueSubscription(subscriber: subscriber, control: control)
subscriber.receive(subscription: subscription)
}
}

@ -0,0 +1,37 @@
//
// NSLayoutConstraint+Multiplier.swift
// SliderControl
//
// Created by Alexander Chekel on 09.09.2023.
// Copyright © 2023 Alexander Chekel. All rights reserved.
//
import UIKit
extension NSLayoutConstraint {
func constraintWithMultiplier(_ newMultiplier: CGFloat) -> NSLayoutConstraint {
let shouldActivate = isActive
if shouldActivate {
NSLayoutConstraint.deactivate([self])
}
let updatedConstraint = NSLayoutConstraint(
item: firstItem as Any,
attribute: firstAttribute,
relatedBy: relation,
toItem: secondItem,
attribute: secondAttribute,
multiplier: newMultiplier,
constant: constant
)
updatedConstraint.priority = priority
updatedConstraint.shouldBeArchived = shouldBeArchived
updatedConstraint.identifier = identifier
if shouldActivate {
NSLayoutConstraint.activate([updatedConstraint])
}
return updatedConstraint
}
}

@ -0,0 +1,270 @@
//
// SliderControl.swift
// SliderControl
//
// Created by Alexander Chekel on 09.09.2023.
// Copyright © 2023 Alexander Chekel. All rights reserved.
//
import UIKit
/// Implements a slider control similar to one found in Apple Music on iOS 16.
open class SliderControl: UIControl {
/// Indicates whether changes in the slider's value generate continuous update events.
/// Default value of this property is `true`.
public var isContinuous: Bool = true
/// A layout guide that follows track size changes in different states.
public let trackLayoutGuide: UILayoutGuide = .init()
/// Indicates whether slider should provide haptic feedback upon reaching minimum or maximum values.
/// Default value of this property is `true`.
open var providesHapticFeedback: Bool = true
/// Feedback generator used to provide haptic feedback when slider reaches minimum or maximum value.
/// Default value of this property is `UIImpactFeedbackGenerator(style: .light)`.
open private(set) var feedbackGenerator: UIFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
/// A color set to track when user is not interacting with the slider.
/// Default value of this property is `secondarySystemFill`.
open var defaultTrackColor: UIColor = .secondarySystemFill {
didSet {
updateColors()
}
}
/// A color set to progress when user is not interacting with the slider.
/// Default value of this property is `.systemFill`.
open var defaultProgressColor: UIColor = .systemFill {
didSet {
updateColors()
}
}
/// A color set to track when user is interacting with the slider.
/// Assigning `nil` to this property disables color changes in interactive state.
/// Default value of this property is `nil`.
open var enlargedTrackColor: UIColor? {
didSet {
updateColors()
}
}
/// A color set to progress when user is interacting with the slider.
/// Assigning `nil` to this property disables color changes in interactive state.
/// Default value of this property is `nil`.
open var enlargedProgressColor: UIColor? {
didSet {
updateColors()
}
}
/// The slider's current value. Ranges between `0.0` and `1.0`.
public var value: Float {
get {
return Float(progressView.bounds.width / trackView.bounds.width)
}
set {
let normalizedValue = max(0.0001, min(1, newValue))
let cgFloatValue = CGFloat(normalizedValue)
progressConstraint = progressConstraint.constraintWithMultiplier(cgFloatValue)
}
}
/// A publisher that emits progress updates when user interacts with the slider.
/// A Combine alternative to adding action for `UIControl.Event.valueChanged`.
public private(set) lazy var valuePublisher: SliderControlValuePublisher = .init(control: self)
public override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: Self.intrinsicHeight)
}
private static let intrinsicHeight: CGFloat = 24
private static let defaultTrackHeight: CGFloat = 7
private static let enlargedTrackHeight: CGFloat = 12
private let trackView: UIView = .init()
private let progressView: UIView = .init()
private var heightConstraint: NSLayoutConstraint = .init()
private var progressConstraint: NSLayoutConstraint = .init()
private var hasPreviousSessionChangedProgress: Bool = false
override public init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required public init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
public override func layoutSubviews() {
super.layoutSubviews()
trackView.layer.cornerRadius = trackView.bounds.height / 2
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
enlargeTrack()
}
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
if let touch = touches.first {
let previousLocation = touch.previousLocation(in: self)
let location = touch.location(in: self)
let translationX = location.x - previousLocation.x
let newWidth = effectiveUserInterfaceLayoutDirection == .leftToRight
? progressView.bounds.width + translationX
: progressView.bounds.width - translationX
let progress = progressView.bounds.width / trackView.bounds.width
let newProgress = max(0, min(1, newWidth / trackView.bounds.width))
if newProgress != progress {
switch newProgress {
case 0:
provideHapticFeedbackForMinimumValue()
case 1:
provideHapticFeedbackForMaximumValue()
default:
break
}
}
let normalizedConstraintMultiplier = max(0.0001, min(1, newProgress))
progressConstraint = progressConstraint.constraintWithMultiplier(normalizedConstraintMultiplier)
hasPreviousSessionChangedProgress = true
if isContinuous {
sendActions(for: .valueChanged)
}
}
}
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
reduceTrack()
if hasPreviousSessionChangedProgress {
hasPreviousSessionChangedProgress = false
sendActions(for: .valueChanged)
}
}
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
reduceTrack()
if hasPreviousSessionChangedProgress {
hasPreviousSessionChangedProgress = false
sendActions(for: .valueChanged)
}
}
private func setup() {
isMultipleTouchEnabled = false
backgroundColor = .clear
trackView.clipsToBounds = true
trackView.backgroundColor = defaultTrackColor
trackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(trackView)
addLayoutGuide(trackLayoutGuide)
progressView.clipsToBounds = true
progressView.backgroundColor = defaultProgressColor
progressView.translatesAutoresizingMaskIntoConstraints = false
trackView.addSubview(progressView)
heightConstraint = trackView.heightAnchor.constraint(equalToConstant: Self.defaultTrackHeight)
progressConstraint = progressView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: 0.5)
NSLayoutConstraint.activate([
heightConstraint,
trackView.centerYAnchor.constraint(equalTo: centerYAnchor),
trackView.leadingAnchor.constraint(equalTo: leadingAnchor),
trackView.trailingAnchor.constraint(equalTo: trailingAnchor),
trackLayoutGuide.topAnchor.constraint(equalTo: trackView.topAnchor),
trackLayoutGuide.leadingAnchor.constraint(equalTo: trackView.leadingAnchor),
trackView.bottomAnchor.constraint(equalTo: trackLayoutGuide.bottomAnchor),
trackView.trailingAnchor.constraint(equalTo: trackLayoutGuide.trailingAnchor),
progressConstraint,
progressView.topAnchor.constraint(equalTo: trackView.topAnchor),
progressView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor),
progressView.bottomAnchor.constraint(equalTo: trackView.bottomAnchor)
])
}
/// The control calls this method upon reaching minimum value. Override this
/// implementation to customize haptic feedback. Your implementation should
/// not call `super.provideHapticFeedbackForMinimumValue()` at any point.
/// You should not call this method directly.
open func provideHapticFeedbackForMinimumValue() {
guard providesHapticFeedback else { return }
(feedbackGenerator as? UIImpactFeedbackGenerator)?.impactOccurred(intensity: 0.75)
}
/// The control calls this method upon reaching maximum value. Override this
/// implementation to customize haptic feedback. Your implementation should
/// not call `super.provideHapticFeedbackForMaximumValue()` at any point.
/// You should not call this method directly.
open func provideHapticFeedbackForMaximumValue() {
guard providesHapticFeedback else { return }
(feedbackGenerator as? UIImpactFeedbackGenerator)?.impactOccurred(intensity: 1)
}
private func enlargeTrack() {
heightConstraint.constant = Self.enlargedTrackHeight
setNeedsLayout()
UIView.animate(
withDuration: 0.25,
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: 0,
options: [.curveEaseIn, .allowAnimatedContent, .allowUserInteraction]
) { [unowned self] in
enlargedTrackColor.map { trackView.backgroundColor = $0 }
enlargedProgressColor.map { progressView.backgroundColor = $0 }
layoutIfNeeded()
}
}
private func reduceTrack() {
heightConstraint.constant = 7
setNeedsLayout()
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: 1,
options: [.curveEaseOut, .allowAnimatedContent, .allowUserInteraction]
) { [unowned self] in
trackView.backgroundColor = defaultTrackColor
progressView.backgroundColor = defaultProgressColor
layoutIfNeeded()
}
}
private func updateColors() {
if heightConstraint.constant == Self.defaultTrackHeight {
trackView.backgroundColor = defaultTrackColor
progressView.backgroundColor = defaultProgressColor
} else {
enlargedTrackColor.map { trackView.backgroundColor = $0 }
enlargedProgressColor.map { progressView.backgroundColor = $0 }
}
}
}
Loading…
Cancel
Save