From 30d03a38945b168bda8b2c73ebbd746ddd7da79e Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 8 Mar 2021 11:42:10 +0800 Subject: [PATCH] chore: add audio support for toot --- .../CoreData.xcdatamodel/contents | 6 +- Mastodon.xcodeproj/project.pbxproj | 28 +++++ .../Diffiable/Section/StatusSection.swift | 15 ++- Mastodon/Extension/Double.swift | 19 +++ Mastodon/Extension/UIControl.swift | 64 ++++++++++ Mastodon/Extension/UIImage.swift | 35 ++++++ Mastodon/Generated/Assets.swift | 3 + .../Colors/Slider/Contents.json | 9 ++ .../Colors/Slider/bar.colorset/Contents.json | 20 +++ .../View/Container/AudioContainerView.swift | 114 ++++++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 13 ++ .../ViewModel/AudioContainerViewModel.swift | 85 +++++++++++++ Mastodon/Service/AudioPlayer.swift | 113 +++++++++++++++++ Mastodon/Service/PlaybackState.swift | 25 ++++ 14 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 Mastodon/Extension/Double.swift create mode 100644 Mastodon/Extension/UIControl.swift create mode 100644 Mastodon/Extension/UIImage.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json create mode 100644 Mastodon/Scene/Share/View/Container/AudioContainerView.swift create mode 100644 Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift create mode 100644 Mastodon/Service/AudioPlayer.swift create mode 100644 Mastodon/Service/PlaybackState.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3f8fe73f..be40ac57 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -163,7 +163,7 @@ - + diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b3abfd84..1653f624 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -22,6 +22,11 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; + 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; + 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; + 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; + 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlayer.swift */; }; + 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; @@ -74,6 +79,8 @@ 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; + 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; + 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; @@ -255,6 +262,11 @@ 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; + 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; + 2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; @@ -303,6 +315,8 @@ 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = ""; }; + 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; + 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -637,6 +651,8 @@ DB45FB0425CA87B4005A8AC7 /* APIService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, + 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, + 2DA6054625F716A2006356F9 /* PlaybackState.swift */, ); path = Service; sourceTree = ""; @@ -1097,6 +1113,9 @@ 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, + 2D206B7F25F5F45E00143C56 /* UIImage.swift */, + 2D206B8525F5FB0900143C56 /* Double.swift */, + 2D206B9125F60EA700143C56 /* UIControl.swift */, ); path = Extension; sourceTree = ""; @@ -1148,6 +1167,7 @@ isa = PBXGroup; children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, + 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, ); path = Container; sourceTree = ""; @@ -1156,6 +1176,7 @@ isa = PBXGroup; children = ( DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, + 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1534,6 +1555,7 @@ files = ( DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, + 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, @@ -1571,6 +1593,7 @@ DB98338825C945ED00AD9700 /* Assets.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, + 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, @@ -1585,8 +1608,10 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, + 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, @@ -1596,6 +1621,7 @@ DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, @@ -1621,6 +1647,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, + 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, @@ -1642,6 +1669,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5f9d43ed..489b9d3b 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -127,8 +127,8 @@ extension StatusSection { }() let scale: CGFloat = { switch mosiacImageViewModel.metas.count { - case 1: return 1.3 - default: return 0.7 + case 1: return 1.3 + default: return 0.7 } }() return CGSize(width: maxWidth, height: maxWidth * scale) @@ -157,6 +157,14 @@ extension StatusSection { cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + // set audio + if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { + cell.statusView.audioView.isHidden = false + AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment) + } else { + cell.statusView.audioView.isHidden = true + } + // set poll let poll = (toot.reblog ?? toot).poll StatusSection.configure( @@ -171,7 +179,7 @@ extension StatusSection { .sink { _ in // do nothing } receiveValue: { change in - guard case let .update(object) = change.changeType, + guard case .update(let object) = change.changeType, let newPoll = object as? Poll else { return } StatusSection.configure( cell: cell, @@ -336,7 +344,6 @@ extension StatusSection { snapshot.appendItems(pollItems, toSection: .main) cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) } - } extension StatusSection { diff --git a/Mastodon/Extension/Double.swift b/Mastodon/Extension/Double.swift new file mode 100644 index 00000000..f485ec2d --- /dev/null +++ b/Mastodon/Extension/Double.swift @@ -0,0 +1,19 @@ +// +// Double.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import Foundation + +extension Double { + func asString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + guard let formattedString = formatter.string(from: self) else { return "" } + return formattedString + } +} diff --git a/Mastodon/Extension/UIControl.swift b/Mastodon/Extension/UIControl.swift new file mode 100644 index 00000000..792e8250 --- /dev/null +++ b/Mastodon/Extension/UIControl.swift @@ -0,0 +1,64 @@ +// +// UIControl.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import Foundation +import UIKit +import Combine + +/// A custom subscription to capture UIControl target events. +final class UIControlSubscription: Subscription where SubscriberType.Input == Control { + private var subscriber: SubscriberType? + private let control: Control + + init(subscriber: SubscriberType, control: Control, event: UIControl.Event) { + self.subscriber = subscriber + self.control = control + control.addTarget(self, action: #selector(eventHandler), for: event) + } + + func request(_ demand: Subscribers.Demand) { + // We do nothing here as we only want to send events when they occur. + // See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand + } + + func cancel() { + subscriber = nil + } + + @objc private func eventHandler() { + _ = subscriber?.receive(control) + } +} + +/// A custom `Publisher` to work with our custom `UIControlSubscription`. +struct UIControlPublisher: Publisher { + + typealias Output = Control + typealias Failure = Never + + let control: Control + let controlEvents: UIControl.Event + + init(control: Control, events: UIControl.Event) { + self.control = control + self.controlEvents = events + } + + func receive(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output { + let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents) + subscriber.receive(subscription: subscription) + } +} + +/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher. +protocol CombineCompatible { } +extension UIControl: CombineCompatible { } +extension CombineCompatible where Self: UIControl { + func publisher(for events: UIControl.Event) -> UIControlPublisher { + return UIControlPublisher(control: self, events: events) + } +} diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift new file mode 100644 index 00000000..e821b676 --- /dev/null +++ b/Mastodon/Extension/UIImage.swift @@ -0,0 +1,35 @@ +// +// UIImage.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import UIKit + +extension UIImage { + class func imageWithColor(color: UIColor, size: CGSize=CGSize(width: 1, height: 1)) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0) + color.setFill() + UIRectFill(CGRect(origin: CGPoint.zero, size: size)) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } + public func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { + let maxRadius = min(size.width, size.height) / 2 + let cornerRadius: CGFloat + if let radius = radius, radius > 0 && radius <= maxRadius { + cornerRadius = radius + } else { + cornerRadius = maxRadius + } + UIGraphicsBeginImageContextWithOptions(size, false, scale) + let rect = CGRect(origin: .zero, size: size) + UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() + draw(in: rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 32786b40..f6817046 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -58,6 +58,9 @@ internal enum Asset { internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") } + internal enum Slider { + internal static let bar = ColorAsset(name: "Colors/Slider/bar") + } internal enum TextField { internal static let highlight = ColorAsset(name: "Colors/TextField/highlight") internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json new file mode 100644 index 00000000..dc91052f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "147", + "green" : "106", + "red" : "51" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift new file mode 100644 index 00000000..cddd7871 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -0,0 +1,114 @@ +// +// AudioViewContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import os.log +import CoreDataStack +import UIKit + + +final class AudioContainerView: UIView { + + static let cornerRadius: CGFloat = 22 + + let container: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = 11 + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 9, bottom: 0, right: 9) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layer.cornerRadius = AudioContainerView.cornerRadius + stackView.clipsToBounds = true + stackView.backgroundColor = Asset.Colors.Button.highlight.color + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.layer.cornerRadius = 16 + view.clipsToBounds = true + view.backgroundColor = Asset.Colors.Button.highlight.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let playButton: UIButton = { + let button = UIButton(type: .custom) + let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! + button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) + + let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! + button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected) + + button.tintColor = .white + button.translatesAutoresizingMaskIntoConstraints = false + button.isEnabled = true + return button + }() + + let slider: UISlider = { + let slider = UISlider() + slider.translatesAutoresizingMaskIntoConstraints = false + slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color + slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color + if let image = UIImage.imageWithColor(color: .white, size: CGSize(width: 22, height: 22))?.withRoundedCorners(radius: 11) { + slider.setThumbImage(image, for: .normal) + } + return slider + }() + + let timeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = .white + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AudioContainerView { + + private func _init() { + + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + //checkmark + checkmarkBackgroundView.addSubview(playButton) + container.addArrangedSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + playButton.centerXAnchor.constraint(equalTo: checkmarkBackgroundView.centerXAnchor), + playButton.centerYAnchor.constraint(equalTo: checkmarkBackgroundView.centerYAnchor), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: 32), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: 32), + ]) + + container.addArrangedSubview(slider) + + container.addArrangedSubview(timeLabel) + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index c1f3cb3d..2713647f 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -156,6 +156,10 @@ final class StatusView: UIView { return imageView }() + let audioView: AudioContainerView = { + let audioView = AudioContainerView() + return audioView + }() let actionToolbarContainer: ActionToolbarContainer = { let actionToolbarContainer = ActionToolbarContainer() actionToolbarContainer.configure(for: .inline) @@ -338,6 +342,14 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + audioView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(audioView) + NSLayoutConstraint.activate([ + audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), + audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), + audioView.heightAnchor.constraint(equalToConstant: 44) + ]) + // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) @@ -346,6 +358,7 @@ extension StatusView { statusMosaicImageViewContainer.isHidden = true pollTableView.isHidden = true pollStatusStackView.isHidden = true + audioView.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift new file mode 100644 index 00000000..ce8d61de --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -0,0 +1,85 @@ +// +// AudioContainerViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/9. +// + +import Foundation +import CoreDataStack +import UIKit + +class AudioContainerViewModel { + static func configure( + cell: StatusTableViewCell, + audioAttachment: Attachment + ) { + guard let duration = audioAttachment.meta?.original?.duration else { return } + let audioView = cell.statusView.audioView + audioView.timeLabel.text = duration.asString(style: .positional) + + audioView.playButton.publisher(for: .touchUpInside) + .sink { button in + if (button.isSelected) { + AudioPlayer.shared.pause() + } else { + if audioAttachment === AudioPlayer.shared.attachment { + if AudioPlayer.shared.currentTimeSubject.value == 0 { + AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + } else { + AudioPlayer.shared.resume() + } + } else { + AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + } + } + } + .store(in: &cell.disposeBag) + audioView.slider.publisher(for: .valueChanged) + .sink { slider in + let slider = slider as! UISlider + let time = Double(slider.value) * duration + + AudioPlayer.shared.seekToTime(time: time) + } + .store(in: &cell.disposeBag) + self.observePlayer(cell:cell, audioAttachment: audioAttachment) + if audioAttachment != AudioPlayer.shared.attachment { + self.resetAudioView(audioView: audioView) + } + } + static func observePlayer( + cell: StatusTableViewCell, + audioAttachment: Attachment + ) { + let audioView = cell.statusView.audioView + AudioPlayer.shared.currentTimeSubject + .receive(on: DispatchQueue.main) + .filter { _ in + audioAttachment === AudioPlayer.shared.attachment + } + .sink(receiveValue: { time in + audioView.timeLabel.text = time.asString(style: .positional) + if let duration = audioAttachment.meta?.original?.duration, !audioView.slider.isTracking { + audioView.slider.setValue(Float(time/duration), animated: true) + } + }) + .store(in: &cell.disposeBag) + AudioPlayer.shared.playbackState + .map { + return $0 == .playing || $0 == .readyToPlay + } + .sink(receiveValue: { isPlaying in + if (audioAttachment === AudioPlayer.shared.attachment) { + audioView.playButton.isSelected = isPlaying + } else { + self.resetAudioView(audioView: audioView) + } + }) + .store(in: &cell.disposeBag) + } + static func resetAudioView(audioView:AudioContainerView) { + audioView.playButton.isSelected = false + audioView.slider.setValue(0, animated: false) + } +} diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift new file mode 100644 index 00000000..4a14a080 --- /dev/null +++ b/Mastodon/Service/AudioPlayer.swift @@ -0,0 +1,113 @@ +// +// AudioPlayer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import AVFoundation +import Combine +import CoreDataStack +import Foundation +import UIKit + +final class AudioPlayer: NSObject { + var disposeBag = Set() + + var player = AVPlayer() + var timeObserver: Any? + var statusObserver: Any? + var attachment: Attachment? + var currentURL: URL? + let session = AVAudioSession.sharedInstance() + let playbackState = CurrentValueSubject(PlaybackState.unknown) + public static let shared = AudioPlayer() + + let currentTimeSubject = CurrentValueSubject(0) + + override init() { + super.init() + addObserver() + } +} + +extension AudioPlayer { + func playAudio(audioAttachment: Attachment) { + guard let url = URL(string: audioAttachment.url) else { + return + } + do { + try session.setCategory(.playback) + } catch { + print(error) + return + } + + if audioAttachment == attachment { + player.play() + return + } + + let playerItem = AVPlayerItem(url: url) + player.replaceCurrentItem(with: playerItem) + attachment = audioAttachment + player.play() + playbackState.send(PlaybackState.playing) + } + + func addObserver() { + UIDevice.current.isProximityMonitoringEnabled = true + NotificationCenter.default.addObserver(self, selector: #selector(proxumityStateChange), name: UIDevice.proximityStateDidChangeNotification, object: nil) + + timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in + guard let self = self else { return } + self.currentTimeSubject.value = time.seconds + }) + player.publisher(for: \.status, options: .new) + .sink(receiveValue: { status in + switch status { + case .failed: + self.playbackState.value = .failed + case .readyToPlay: + self.playbackState.value = .readyToPlay + case .unknown: + self.playbackState.value = .unknown + @unknown default: + fatalError() + } + }) + .store(in: &disposeBag) + } + + @objc func proxumityStateChange(notification: NSNotification) { + if UIDevice.current.proximityState == true { + do { + try session.setCategory(.playAndRecord) + } catch { + print(error) + return + } + } else { + do { + try session.setCategory(.playback) + } catch { + print(error) + return + } + } + } + + func resume() { + player.play() + playbackState.send(PlaybackState.playing) + } + + func pause() { + player.pause() + playbackState.send(PlaybackState.paused) + } + + func seekToTime(time: TimeInterval) { + player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) + } +} diff --git a/Mastodon/Service/PlaybackState.swift b/Mastodon/Service/PlaybackState.swift new file mode 100644 index 00000000..75fced7b --- /dev/null +++ b/Mastodon/Service/PlaybackState.swift @@ -0,0 +1,25 @@ +// +// PlaybackState.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/9. +// + +import Foundation + +public enum PlaybackState : Int { + + case unknown = 0 + + case buffering = 1 + + case readyToPlay = 2 + + case playing = 3 + + case paused = 4 + + case stopped = 5 + + case failed = 6 +}