From 5146380302eb7520396fb1a871e0e66fc93d29c6 Mon Sep 17 00:00:00 2001 From: wenlei Date: Fri, 19 Jan 2024 20:17:54 +0800 Subject: [PATCH] Custom player slider --- .../IndieMusic.xcodeproj/project.pbxproj | 30 +- .../IndieMusic/Extensions/AVPlayer.swift | 6 +- .../NSNotification+IndieMusic.swift | 7 + .../IndieMusic/Managers/AudioManager.swift | 45 ++- IndieMusic/IndieMusic/Models/Player.swift | 52 ++++ .../Modules/Home/HomeTabBarController.swift | 32 ++- .../Modules/Home/HomeTabBarViewModel.swift | 15 +- .../Modules/Home/SongViewCell.swift | 5 +- .../Player/AudioTrackListViewController.swift | 19 +- .../Player/AudioTrackListViewModel.swift | 16 +- .../Modules/Player/PlayerTabBar.swift | 90 +++++- .../Modules/Player/PlayerView.swift | 192 +++++++++++-- .../Modules/Player/PlayerViewController.swift | 46 ++- .../Modules/Player/PlayerViewModel.swift | 115 ++++++-- .../play_ thumb_icon.imageset/Contents.json | 21 ++ .../play_ thumb_icon.svg | 3 + .../Contents.json | 21 ++ .../player_shuffle_random.svg | 3 + .../Contents.json | 21 ++ .../player_shuffle_singleLoop.svg | 3 + .../Combine/SliderControlValuePublisher.swift | 56 ++++ .../NSLayoutConstraint+Multiplier.swift | 37 +++ .../SliderControl/SliderControl.swift | 270 ++++++++++++++++++ 23 files changed, 1025 insertions(+), 80 deletions(-) create mode 100644 IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/Contents.json create mode 100644 IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/play_ thumb_icon.svg create mode 100644 IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/Contents.json create mode 100644 IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/player_shuffle_random.svg create mode 100644 IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/Contents.json create mode 100644 IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/player_shuffle_singleLoop.svg create mode 100644 IndieMusic/IndieMusic/Third Party/SliderControl/Combine/SliderControlValuePublisher.swift create mode 100644 IndieMusic/IndieMusic/Third Party/SliderControl/NSLayoutConstraint+Multiplier.swift create mode 100644 IndieMusic/IndieMusic/Third Party/SliderControl/SliderControl.swift diff --git a/IndieMusic/IndieMusic.xcodeproj/project.pbxproj b/IndieMusic/IndieMusic.xcodeproj/project.pbxproj index a07ecf7..325fd26 100644 --- a/IndieMusic/IndieMusic.xcodeproj/project.pbxproj +++ b/IndieMusic/IndieMusic.xcodeproj/project.pbxproj @@ -16,6 +16,9 @@ 770228ED2B55284F00E07F7A /* Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228EC2B55284F00E07F7A /* Login.swift */; }; 770228EF2B56142E00E07F7A /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770228EE2B56142E00E07F7A /* User.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 */; }; 7736FF442B4CECF2008D5DAD /* CommentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736FF432B4CECF2008D5DAD /* CommentViewModel.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 = ""; }; 770228EE2B56142E00E07F7A /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 770228F02B57AD2C00E07F7A /* RefreshLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshLoadingView.swift; sourceTree = ""; }; + 770228F42B5A224500E07F7A /* SliderControlValuePublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderControlValuePublisher.swift; sourceTree = ""; }; + 770228F52B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Multiplier.swift"; sourceTree = ""; }; + 770228F62B5A224500E07F7A /* SliderControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderControl.swift; sourceTree = ""; }; 77165D732B464493002AE0A5 /* BarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarButtonItem.swift; sourceTree = ""; }; 7736FF432B4CECF2008D5DAD /* CommentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewModel.swift; sourceTree = ""; }; 7736FF452B4CF0E6008D5DAD /* CommentDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailViewController.swift; sourceTree = ""; }; @@ -434,6 +440,24 @@ path = RxActivityIndicator; sourceTree = ""; }; + 770228F22B5A224500E07F7A /* SliderControl */ = { + isa = PBXGroup; + children = ( + 770228F32B5A224500E07F7A /* Combine */, + 770228F52B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift */, + 770228F62B5A224500E07F7A /* SliderControl.swift */, + ); + path = SliderControl; + sourceTree = ""; + }; + 770228F32B5A224500E07F7A /* Combine */ = { + isa = PBXGroup; + children = ( + 770228F42B5A224500E07F7A /* SliderControlValuePublisher.swift */, + ); + path = Combine; + sourceTree = ""; + }; 7743999C2AFA18B0006F8EEA /* Player */ = { isa = PBXGroup; children = ( @@ -672,6 +696,7 @@ 778B8A5A2AF8EAFD0034AFD4 /* Third Party */ = { isa = PBXGroup; children = ( + 770228F22B5A224500E07F7A /* SliderControl */, 770228E32B54FA3600E07F7A /* RxActivityIndicator */, 778B8A602AF8ECC20034AFD4 /* RxErrorTracker */, 7751D3812B45324300F1F2BD /* IndieMusic-Bridging-Header.h */, @@ -748,10 +773,10 @@ isa = PBXGroup; children = ( 778B8A772AF8ECE50034AFD4 /* HomeTabBarViewModel.swift */, + 778B8A7A2AF8ECE50034AFD4 /* HomeTabBarController.swift */, 778B8A782AF8ECE50034AFD4 /* HomeViewModel.swift */, 778B8A792AF8ECE50034AFD4 /* HomeViewController.swift */, 774A18062B06045200F56DF1 /* HomeView.swift */, - 778B8A7A2AF8ECE50034AFD4 /* HomeTabBarController.swift */, 774A180F2B070A6900F56DF1 /* SongViewCell.swift */, 774A18112B07327C00F56DF1 /* CommentCountButton.swift */, 774A18132B07329600F56DF1 /* MultiUserAvatarView.swift */, @@ -1148,6 +1173,7 @@ 7751D3782B43EA1200F1F2BD /* SearchResultsController.swift in Sources */, 7751D35A2B42B5BD00F1F2BD /* EditInfoView.swift in Sources */, 7751D35E2B42B61100F1F2BD /* PersonInfo.swift in Sources */, + 770228F72B5A224500E07F7A /* SliderControlValuePublisher.swift in Sources */, 774399A82AFE28BA006F8EEA /* BlurEffectView.swift in Sources */, 778B8A852AF8ECE50034AFD4 /* SearchViewController.swift in Sources */, 770228E92B55169C00E07F7A /* InternationalNumber.swift in Sources */, @@ -1201,6 +1227,7 @@ 778B8ABB2AF8ED280034AFD4 /* WebViewController.swift in Sources */, 774A17F72B04932100F56DF1 /* SegmentControl.swift in Sources */, 77FA0B282B0B3E1E00404C5E /* Journal.swift in Sources */, + 770228F82B5A224500E07F7A /* NSLayoutConstraint+Multiplier.swift in Sources */, 77FA0B5A2B147EC900404C5E /* CommentViewController.swift in Sources */, 77FA0B402B0D8E9300404C5E /* LayoutableButton.swift in Sources */, 77C9B9C22B4AB6C10006C83F /* AboutViewModel.swift in Sources */, @@ -1255,6 +1282,7 @@ 7751D37C2B4516BE00F1F2BD /* UILabel+IndieMusic.swift in Sources */, 778B8AAC2AF8ED0E0034AFD4 /* UIViewController+Rx.swift in Sources */, 778B8A8D2AF8ECF20034AFD4 /* APIImage.swift in Sources */, + 770228F92B5A224500E07F7A /* SliderControl.swift in Sources */, 7751D36A2B42ED6C00F1F2BD /* TimingViewController.swift in Sources */, 77FA0B462B0DFAFD00404C5E /* BindPhoneViewController.swift in Sources */, 77FA0B382B0C54C700404C5E /* FilterViewController.swift in Sources */, diff --git a/IndieMusic/IndieMusic/Extensions/AVPlayer.swift b/IndieMusic/IndieMusic/Extensions/AVPlayer.swift index 345545b..a0ce0df 100644 --- a/IndieMusic/IndieMusic/Extensions/AVPlayer.swift +++ b/IndieMusic/IndieMusic/Extensions/AVPlayer.swift @@ -9,12 +9,14 @@ import Foundation import AVFoundation 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 if let duration = self.currentItem?.duration { let duration = CMTimeGetSeconds(duration), time = CMTimeGetSeconds(time) let progress = (time/duration) - completion(progress) + + print("1媒体文件总时长: \(duration) 秒") + completion(time, duration, progress) } }) } diff --git a/IndieMusic/IndieMusic/Extensions/NSNotification+IndieMusic.swift b/IndieMusic/IndieMusic/Extensions/NSNotification+IndieMusic.swift index 0e9f79f..ee017c1 100644 --- a/IndieMusic/IndieMusic/Extensions/NSNotification+IndieMusic.swift +++ b/IndieMusic/IndieMusic/Extensions/NSNotification+IndieMusic.swift @@ -15,6 +15,13 @@ extension Notification.Name { /// 当前播放歌曲 static let notiPlayAudioTrack = Notification.Name("notiPlayAudioTrack") + /// 暂停 + static let notiPlayPause = Notification.Name("notiPlayPause") + /// 播放 + static let notiPlayResume = Notification.Name("notiPlayResume") + /// 播放模式 + static let notiPlayShuffle = Notification.Name("notiPlayShuffle") + } diff --git a/IndieMusic/IndieMusic/Managers/AudioManager.swift b/IndieMusic/IndieMusic/Managers/AudioManager.swift index 504f308..f24d518 100644 --- a/IndieMusic/IndieMusic/Managers/AudioManager.swift +++ b/IndieMusic/IndieMusic/Managers/AudioManager.swift @@ -14,9 +14,6 @@ class AudioManager { static let sharedInstance = AudioManager() let commandCenter = MPRemoteCommandCenter.shared() - - - init() { NotificationCenter.default @@ -62,6 +59,13 @@ class AudioManager { var repeatActive = false var shuffleActive = false + var playerShuffleType: PlayerShuffleType = .sequential + + var isSeekInProgress = false + var chasingTime = CMTime.zero + + + func setPlaylist(list: [AudioTrack]) { self.playlist = list } @@ -154,12 +158,47 @@ class AudioManager { public func pause() { if let player = player { player.pause() + NotificationCenter.default.post(name: .notiPlayPause, object: nil) } } public func resume() { if let player = player { 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() + } } } diff --git a/IndieMusic/IndieMusic/Models/Player.swift b/IndieMusic/IndieMusic/Models/Player.swift index 8f73a9d..107dcd6 100644 --- a/IndieMusic/IndieMusic/Models/Player.swift +++ b/IndieMusic/IndieMusic/Models/Player.swift @@ -8,6 +8,58 @@ import Foundation 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 { let lyrics: String? } diff --git a/IndieMusic/IndieMusic/Modules/Home/HomeTabBarController.swift b/IndieMusic/IndieMusic/Modules/Home/HomeTabBarController.swift index 877ac33..396aec9 100644 --- a/IndieMusic/IndieMusic/Modules/Home/HomeTabBarController.swift +++ b/IndieMusic/IndieMusic/Modules/Home/HomeTabBarController.swift @@ -160,7 +160,10 @@ class HomeTabBarController: UITabBarController, Navigatable { func bindViewModel() { 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) 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 - -// let audioTrack = AudioTrack.init(album: nil, -// 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") - - - -// AudioManager.sharedInstance.playTrack(track: audioTrack) + if AudioManager.sharedInstance.isPlaying() { + AudioManager.sharedInstance.pause() + } else { + AudioManager.sharedInstance.resume() + } }.disposed(by: rx.disposeBag) @@ -199,6 +200,21 @@ class HomeTabBarController: UITabBarController, Navigatable { }.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) + + } diff --git a/IndieMusic/IndieMusic/Modules/Home/HomeTabBarViewModel.swift b/IndieMusic/IndieMusic/Modules/Home/HomeTabBarViewModel.swift index 3f60c9b..43926f5 100644 --- a/IndieMusic/IndieMusic/Modules/Home/HomeTabBarViewModel.swift +++ b/IndieMusic/IndieMusic/Modules/Home/HomeTabBarViewModel.swift @@ -13,11 +13,19 @@ import SVProgressHUD class HomeTabBarViewModel: ViewModel, ViewModelType { struct Input { - + let notiPlayAudioTrack: Observable + let notiPlayPause: Observable + let notiPlayResume: Observable + } struct Output { let tabBarItems: Driver<[HomeTabBarItem]> + let notiPlayAudioTrack: Observable + let notiPlayPause: Observable + let notiPlayResume: Observable + + } 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 { diff --git a/IndieMusic/IndieMusic/Modules/Home/SongViewCell.swift b/IndieMusic/IndieMusic/Modules/Home/SongViewCell.swift index b7362eb..dd3b2a9 100644 --- a/IndieMusic/IndieMusic/Modules/Home/SongViewCell.swift +++ b/IndieMusic/IndieMusic/Modules/Home/SongViewCell.swift @@ -74,13 +74,10 @@ class SongViewCell: UITableViewCell { detailLabel.text = (audioTrack.artist ?? "") + "/" + (audioTrack.album ?? "") coverView.kf.setImage(with: URL.init(string: audioTrack.pic ?? "")) - + if AudioManager.sharedInstance.currentTrack?.id == audioTrack.id { musicIndicator.isHidden = false musicIndicator.state = AudioManager.sharedInstance.isPlaying() ? .playing : .paused - - - } else { musicIndicator.isHidden = true } diff --git a/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewController.swift b/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewController.swift index a5f9dc7..2fc0c54 100644 --- a/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewController.swift +++ b/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewController.swift @@ -58,7 +58,8 @@ class AudioTrackListViewController: ViewController { guard let viewModel = viewModel as? AudioTrackListViewModel else { return } 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 dataSource = AudioTrackListViewController.dataSource { [weak self] cell, audioTrack in @@ -78,6 +79,22 @@ class AudioTrackListViewController: ViewController { }.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_footer = nil diff --git a/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewModel.swift b/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewModel.swift index c6fb0b9..b58b3f5 100644 --- a/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewModel.swift +++ b/IndieMusic/IndieMusic/Modules/Player/AudioTrackListViewModel.swift @@ -16,18 +16,21 @@ class AudioTrackListViewModel: ViewModel, ViewModelType { struct Input { let viewWillAppear: ControlEvent let selection: Driver + let notiPlayAudioTrack: Observable } struct Output { let items: BehaviorRelay<[JournalSection]> let itemSelected: PublishSubject + let reloadData: PublishRelay } let items = BehaviorRelay<[JournalSection]>.init(value: []) let itemSelected = PublishSubject() + let reloadData = PublishRelay() func transform(input: Input) -> Output { @@ -56,9 +59,20 @@ class AudioTrackListViewModel: ViewModel, ViewModelType { }.disposed(by: rx.disposeBag) + + 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, - itemSelected: itemSelected) + itemSelected: itemSelected, + reloadData: reloadData) } } diff --git a/IndieMusic/IndieMusic/Modules/Player/PlayerTabBar.swift b/IndieMusic/IndieMusic/Modules/Player/PlayerTabBar.swift index e4ba4be..6c6dde6 100644 --- a/IndieMusic/IndieMusic/Modules/Player/PlayerTabBar.swift +++ b/IndieMusic/IndieMusic/Modules/Player/PlayerTabBar.swift @@ -6,6 +6,7 @@ // import UIKit +import MarqueeLabel class PlayerTabBar: UIControl { private var coverView: UIImageView = { @@ -22,18 +23,25 @@ class PlayerTabBar: UIControl { }() 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 }() - private var artistLabel: UILabel = { - var artistLabel = UILabel.init() + private var artistLabel: MarqueeLabel = { + let artistLabel = MarqueeLabel.init(frame: .zero, rate: 15, fadeLength: 10) + artistLabel.font = UIFont.systemFont(ofSize: 12) + artistLabel.textColor = .tertiaryText() + return artistLabel }() var playButton: UIButton = { var playButton = UIButton.init() playButton.setImage(UIImage.init(named: "playerBar_play_btn"), for: .normal) + playButton.setImage(UIImage.init(named: "playerBar_play_btn"), for: .selected) return playButton }() @@ -50,7 +58,16 @@ 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) { super.init(frame: frame) @@ -131,6 +148,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") + } + diff --git a/IndieMusic/IndieMusic/Modules/Player/PlayerView.swift b/IndieMusic/IndieMusic/Modules/Player/PlayerView.swift index 7e43f2a..3742296 100644 --- a/IndieMusic/IndieMusic/Modules/Player/PlayerView.swift +++ b/IndieMusic/IndieMusic/Modules/Player/PlayerView.swift @@ -8,6 +8,7 @@ import UIKit import SVProgressHUD import MarqueeLabel +import AVFoundation class PlayerViewTopBar: UIView { lazy var dropButton: UIButton = { @@ -211,7 +212,6 @@ class PlayerScrollView: UIScrollView { playerInfoView.numberLabel.text = "VOL \(audioTrack.journalNo ?? "")" playerInfoView.updateData(audioTrack: audioTrack) - SVProgressHUD.showText(withStatus: audioTrack.title) print("audioTrack \(audioTrack)") } @@ -273,6 +273,28 @@ class PlayerScrollView: UIScrollView { 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 { @@ -517,30 +539,14 @@ class PlayerInfoView: UIView { return likeButton }() - var playerSlider: UISlider = { - let playerSlider = UISlider.init() - playerSlider.tintColor = .white - + var playerSlider: PlayerSlider = { + let playerSlider = PlayerSlider.init() 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) { super.init(frame: frame) @@ -554,6 +560,7 @@ class PlayerInfoView: UIView { // endTimeLabel.text = "00:00" // // coverView.backgroundColor = .gray + } required init?(coder: NSCoder) { @@ -570,8 +577,6 @@ class PlayerInfoView: UIView { addSubview(shareButton) addSubview(likeButton) addSubview(playerSlider) - addSubview(startTimeLabel) - addSubview(endTimeLabel) } @@ -630,22 +635,155 @@ class PlayerInfoView: UIView { playerSlider.snp.makeConstraints { make in make.left.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 - make.left.equalTo(self).offset(18) - make.top.equalTo(playerSlider.snp.bottom).offset(3) + make.left.equalTo(self) + make.top.equalTo(slider.snp.bottom).offset(10) make.bottom.equalTo(self).offset(-10) } endTimeLabel.snp.makeConstraints { make in - make.right.equalTo(self).offset(-18) - make.top.equalTo(playerSlider.snp.bottom).offset(3) + make.right.equalTo(self) + make.top.equalTo(slider.snp.bottom).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) + + } +} diff --git a/IndieMusic/IndieMusic/Modules/Player/PlayerViewController.swift b/IndieMusic/IndieMusic/Modules/Player/PlayerViewController.swift index cb993b3..830ef2c 100644 --- a/IndieMusic/IndieMusic/Modules/Player/PlayerViewController.swift +++ b/IndieMusic/IndieMusic/Modules/Player/PlayerViewController.swift @@ -97,7 +97,8 @@ class PlayerViewController: ViewController { nextButtonTrigger: playerControlView.nextButton.rx.tap.asDriver(), playButtonTrigger: playerControlView.playButton.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) @@ -118,10 +119,15 @@ class PlayerViewController: ViewController { viewModel.isLike.subscribe { [weak self] isLike in self?.playerScrollView.playerInfoView.likeButton.isSelected = isLike - - } .disposed(by: rx.disposeBag) + }.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) + + viewModel.isPlaying .bind(to: self.playerControlView.playButton.rx.isSelected) @@ -139,7 +145,41 @@ class PlayerViewController: ViewController { }.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 diff --git a/IndieMusic/IndieMusic/Modules/Player/PlayerViewModel.swift b/IndieMusic/IndieMusic/Modules/Player/PlayerViewModel.swift index 80eb483..18d21ca 100644 --- a/IndieMusic/IndieMusic/Modules/Player/PlayerViewModel.swift +++ b/IndieMusic/IndieMusic/Modules/Player/PlayerViewModel.swift @@ -8,6 +8,7 @@ import Foundation import RxSwift import RxCocoa +import RxOptional class PlayerViewModel: ViewModel, ViewModelType { @@ -22,16 +23,23 @@ class PlayerViewModel: ViewModel, ViewModelType { let playButtonTrigger: Driver let moreButtonTrigger: Driver let notiPlayAudioTrack: Observable + let notiPlayShuffle: Observable + } struct Output { let items: BehaviorRelay<[PlayerLyricsSection]> - let isLike: BehaviorRelay +// let isLike: BehaviorRelay let shuffle: BehaviorRelay let audioTrack: PublishSubject - let toShare: BehaviorRelay + let toShare: PublishSubject let toList: BehaviorRelay + let playShuffleType: BehaviorRelay + let progress: BehaviorRelay + let duration: BehaviorRelay + let time: BehaviorRelay + } let items = BehaviorRelay<[PlayerLyricsSection]>.init(value: []) @@ -41,9 +49,16 @@ class PlayerViewModel: ViewModel, ViewModelType { let isPlaying = BehaviorRelay.init(value: false) let shuffle = BehaviorRelay.init(value: false) let audioTrack = PublishSubject.init() - let toShare = BehaviorRelay.init(value: ()) + let toShare = PublishSubject.init() let toList = BehaviorRelay.init(value: ()) + let playShuffleType = BehaviorRelay.init(value: .sequential) + let progress = BehaviorRelay.init(value: 0) + let duration = BehaviorRelay.init(value: 0) + let time = BehaviorRelay.init(value: 0) + + + var trackINfo: AudioTrack? init(track: AudioTrack?, provider: IndieMusicAPI) { @@ -52,39 +67,40 @@ class PlayerViewModel: ViewModel, ViewModelType { print("audioTrack333 ") self.trackINfo = 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 { + input.likeButtonTrigger.drive { [weak self] _ in + guard let self = self else { return } + self.isLike.accept(!self.isLike.value) + }.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.shareButtonTrigger.drive { _ in + self.toShare.onNext(()) + }.disposed(by: rx.disposeBag) + + 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]#>) - -// -// let audioTrack = AudioTrack.init(album: nil, -// 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") - - - -// AudioManager.sharedInstance.setPlaylist(list: [audioTrack]) -// AudioManager.sharedInstance.playTrack(track: audioTrack) - if AudioManager.sharedInstance.isPlaying() { AudioManager.sharedInstance.pause() } else { @@ -117,15 +133,66 @@ class PlayerViewModel: ViewModel, ViewModelType { guard let track = noti.element?.object as? AudioTrack else { return } 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) + + + return Output.init(items: items, - isLike: isLike, +// isLike: isLike, shuffle: shuffle, audioTrack: audioTrack, 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)) + } + } +} + diff --git a/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/Contents.json b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/Contents.json new file mode 100644 index 0000000..d1a0b4b --- /dev/null +++ b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/Contents.json @@ -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 + } +} diff --git a/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/play_ thumb_icon.svg b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/play_ thumb_icon.svg new file mode 100644 index 0000000..1126379 --- /dev/null +++ b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_ thumb_icon.imageset/play_ thumb_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/Contents.json b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/Contents.json new file mode 100644 index 0000000..608d9cc --- /dev/null +++ b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/Contents.json @@ -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 + } +} diff --git a/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/player_shuffle_random.svg b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/player_shuffle_random.svg new file mode 100644 index 0000000..9b47cc7 --- /dev/null +++ b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_random.imageset/player_shuffle_random.svg @@ -0,0 +1,3 @@ + + + diff --git a/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/Contents.json b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/Contents.json new file mode 100644 index 0000000..7b4ec07 --- /dev/null +++ b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/Contents.json @@ -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 + } +} diff --git a/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/player_shuffle_singleLoop.svg b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/player_shuffle_singleLoop.svg new file mode 100644 index 0000000..bbd2476 --- /dev/null +++ b/IndieMusic/IndieMusic/Resources/Assets.xcassets/play/play_shuffle_singleLoop.imageset/player_shuffle_singleLoop.svg @@ -0,0 +1,3 @@ + + + diff --git a/IndieMusic/IndieMusic/Third Party/SliderControl/Combine/SliderControlValuePublisher.swift b/IndieMusic/IndieMusic/Third Party/SliderControl/Combine/SliderControlValuePublisher.swift new file mode 100644 index 0000000..b537bf1 --- /dev/null +++ b/IndieMusic/IndieMusic/Third Party/SliderControl/Combine/SliderControlValuePublisher.swift @@ -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: 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(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) + } +} diff --git a/IndieMusic/IndieMusic/Third Party/SliderControl/NSLayoutConstraint+Multiplier.swift b/IndieMusic/IndieMusic/Third Party/SliderControl/NSLayoutConstraint+Multiplier.swift new file mode 100644 index 0000000..8b2e983 --- /dev/null +++ b/IndieMusic/IndieMusic/Third Party/SliderControl/NSLayoutConstraint+Multiplier.swift @@ -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 + } +} diff --git a/IndieMusic/IndieMusic/Third Party/SliderControl/SliderControl.swift b/IndieMusic/IndieMusic/Third Party/SliderControl/SliderControl.swift new file mode 100644 index 0000000..52c1819 --- /dev/null +++ b/IndieMusic/IndieMusic/Third Party/SliderControl/SliderControl.swift @@ -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, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + enlargeTrack() + } + + public override func touchesMoved(_ touches: Set, 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, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + reduceTrack() + + if hasPreviousSessionChangedProgress { + hasPreviousSessionChangedProgress = false + sendActions(for: .valueChanged) + } + } + + public override func touchesCancelled(_ touches: Set, 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 } + } + } +}