Merge pull request #51 from tootsuite/feature/audio

Feature/audio
This commit is contained in:
sxiaojian88 2021-03-09 19:37:31 +08:00 committed by GitHub
commit 1b654dcabc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 579 additions and 25 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
@ -16,7 +16,7 @@
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="metaData" optional="YES" attributeType="Binary"/>
<attribute name="previewRemoteURL" optional="YES" attributeType="String"/>
<attribute name="previewURL" attributeType="String"/>
<attribute name="previewURL" optional="YES" attributeType="String"/>
<attribute name="remoteURL" optional="YES" attributeType="String"/>
<attribute name="textURL" optional="YES" attributeType="String"/>
<attribute name="typeRaw" attributeType="String"/>
@ -163,7 +163,7 @@
</entity>
<elements>
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
<element name="Attachment" positionX="72" positionY="162" width="128" height="14"/>
<element name="Attachment" positionX="72" positionY="162" width="128" height="254"/>
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>

View File

@ -15,7 +15,7 @@ public final class Attachment: NSManagedObject {
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var typeRaw: String
@NSManaged public private(set) var url: String
@NSManaged public private(set) var previewURL: String
@NSManaged public private(set) var previewURL: String?
@NSManaged public private(set) var remoteURL: String?
@NSManaged public private(set) var metaData: Data?
@ -80,7 +80,7 @@ public extension Attachment {
public let typeRaw: String
public let url: String
public let previewURL: String
public let previewURL: String?
public let remoteURL: String?
public let metaData: Data?
public let textURL: String?
@ -95,7 +95,7 @@ public extension Attachment {
id: Attachment.ID,
typeRaw: String,
url: String,
previewURL: String,
previewURL: String?,
remoteURL: String?,
metaData: Data?,
textURL: String?,

View File

@ -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 */; };
@ -44,7 +49,6 @@
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; };
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; };
@ -74,6 +78,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 +261,11 @@
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
@ -276,7 +287,6 @@
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = "<group>"; };
@ -303,6 +313,8 @@
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = "<group>"; };
2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = "<group>"; };
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = "<group>"; };
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
@ -637,6 +649,8 @@
DB45FB0425CA87B4005A8AC7 /* APIService */,
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
2D206B8B25F6015000143C56 /* AudioPlayer.swift */,
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
);
path = Service;
sourceTree = "<group>";
@ -1085,7 +1099,6 @@
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
2D46976325C2A71500CF4AA9 /* UIIamge.swift */,
2DF123A625C3B0210020F248 /* ActiveLabel.swift */,
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */,
2D42FF6A25C817D2004A627A /* MastodonContent.swift */,
@ -1097,6 +1110,9 @@
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
0FAA101B25E10E760017CCDE /* UIFont.swift */,
2D939AB425EDD8A90076FA61 /* String.swift */,
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
2D206B8525F5FB0900143C56 /* Double.swift */,
2D206B9125F60EA700143C56 /* UIControl.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -1148,6 +1164,7 @@
isa = PBXGroup;
children = (
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
);
path = Container;
sourceTree = "<group>";
@ -1156,6 +1173,7 @@
isa = PBXGroup;
children = (
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -1534,6 +1552,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 +1590,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,17 +1605,19 @@
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 */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
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 +1643,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 +1665,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 */,

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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<SubscriberType: Subscriber, Control: UIControl>: 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<Control: UIControl>: 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<S>(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<UIControl> {
return UIControlPublisher(control: self, events: events)
}
}

View File

@ -1,16 +1,15 @@
//
// UIIamge.swift
// UIImage.swift
// Mastodon
//
// Created by sxiaojian on 2021/1/28.
// Created by sxiaojian on 2021/3/8.
//
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit
extension UIImage {
static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage {
let render = UIGraphicsImageRenderer(size: size)
@ -19,7 +18,6 @@ extension UIImage {
context.fill(CGRect(origin: .zero, size: size))
}
}
}
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
@ -53,3 +51,20 @@ extension UIImage {
return image
}
}
public extension UIImage {
func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
let maxRadius = min(size.width, size.height) / 2
let cornerRadius: CGFloat = {
guard let radius = radius, radius > 0 else { return maxRadius }
return min(radius, maxRadius)
}()
let render = UIGraphicsImageRenderer(size: size)
return render.image { (_: UIGraphicsImageRendererContext) in
let rect = CGRect(origin: .zero, size: size)
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
draw(in: rect)
}
}
}

View File

@ -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")

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,111 @@
//
// AudioViewContainer.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/8.
//
import CoreDataStack
import os.log
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: 8, bottom: 0, right: 8)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layer.cornerRadius = AudioContainerView.cornerRadius
stackView.clipsToBounds = true
stackView.backgroundColor = Asset.Colors.Button.highlight.color
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
let playButtonBackgroundView: 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 = HighlightDimmableButton(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.placeholder(size: CGSize(width: 22, height: 22), color: .white).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
playButtonBackgroundView.addSubview(playButton)
container.addArrangedSubview(playButtonBackgroundView)
NSLayoutConstraint.activate([
playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor),
playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor),
playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32),
playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32),
])
container.addArrangedSubview(slider)
container.addArrangedSubview(timeLabel)
NSLayoutConstraint.activate([
timeLabel.widthAnchor.constraint(equalToConstant: 40),
])
}
}

View File

@ -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

View File

@ -0,0 +1,111 @@
//
// AudioContainerViewModel.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/9.
//
import CoreDataStack
import Foundation
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 { _ in
if audioAttachment === AudioPlayer.shared.attachment {
if AudioPlayer.shared.isPlaying() {
AudioPlayer.shared.pause()
} else {
AudioPlayer.shared.resume()
}
if AudioPlayer.shared.currentTimeSubject.value == 0 {
AudioPlayer.shared.playAudio(audioAttachment: audioAttachment)
}
} 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 {
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
}
}
static func observePlayer(
cell: StatusTableViewCell,
audioAttachment: Attachment
) {
let audioView = cell.statusView.audioView
var lastCurrentTimeSubject: TimeInterval?
AudioPlayer.shared.currentTimeSubject
.throttle(for: 0.33, scheduler: DispatchQueue.main, latest: true)
.compactMap { time -> (TimeInterval, Float)? in
defer {
lastCurrentTimeSubject = time
}
guard audioAttachment === AudioPlayer.shared.attachment else { return nil }
guard let duration = audioAttachment.meta?.original?.duration else { return nil }
if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 {
guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce
}
guard !audioView.slider.isTracking else { return nil }
return (time, Float(time / duration))
}
.sink(receiveValue: { time, progress in
audioView.timeLabel.text = time.asString(style: .positional)
audioView.slider.setValue(progress, animated: true)
})
.store(in: &cell.disposeBag)
AudioPlayer.shared.playbackState
.receive(on: DispatchQueue.main)
.sink(receiveValue: { playbackState in
if audioAttachment === AudioPlayer.shared.attachment {
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState)
} else {
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
}
})
.store(in: &cell.disposeBag)
}
static func configureAudioView(
audioView: AudioContainerView,
audioAttachment: Attachment,
playbackState: PlaybackState
) {
switch playbackState {
case .stopped:
audioView.playButton.isSelected = false
audioView.slider.isEnabled = false
audioView.slider.setValue(0, animated: false)
case .paused:
audioView.playButton.isSelected = false
audioView.slider.isEnabled = true
case .playing, .readyToPlay:
audioView.playButton.isSelected = true
audioView.slider.isEnabled = true
default:
assertionFailure()
}
guard let duration = audioAttachment.meta?.original?.duration else { return }
audioView.timeLabel.text = duration.asString(style: .positional)
}
}

View File

@ -16,7 +16,8 @@ struct MosaicImageViewModel {
var metas: [MosaicMeta] = []
for element in mediaAttachments where element.type == .image {
// Display original on the iPad/Mac
let urlString = UIDevice.current.userInterfaceIdiom == .phone ? element.previewURL : element.url
guard let previewURL = element.previewURL else { continue }
let urlString = UIDevice.current.userInterfaceIdiom == .phone ? previewURL : element.url
guard let meta = element.meta,
let width = meta.original?.width,
let height = meta.original?.height,

View File

@ -0,0 +1,132 @@
//
// 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<AnyCancellable>()
var player = AVPlayer()
var timeObserver: Any?
var statusObserver: Any?
var attachment: Attachment?
let session = AVAudioSession.sharedInstance()
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
// MARK: - singleton
public static let shared = AudioPlayer()
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
private 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 {
if self.playbackState.value == .stopped {
self.seekToTime(time: .zero)
}
player.play()
self.playbackState.value = .playing
return
}
player.pause()
let playerItem = AVPlayerItem(url: url)
player.replaceCurrentItem(with: playerItem)
attachment = audioAttachment
player.play()
playbackState.value = .playing
}
func addObserver() {
UIDevice.current.isProximityMonitoringEnabled = true
NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil)
.sink { [weak self] _ in
guard let self = self else { return }
if UIDevice.current.proximityState == true {
do {
try self.session.setCategory(.playAndRecord)
} catch {
print(error)
return
}
} else {
do {
try self.session.setCategory(.playback)
} catch {
print(error)
return
}
}
}
.store(in: &disposeBag)
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: { [weak self] status in
guard let self = self else { return }
switch status {
case .failed:
self.playbackState.value = .failed
case .readyToPlay:
self.playbackState.value = .readyToPlay
case .unknown:
self.playbackState.value = .unknown
@unknown default:
assertionFailure()
}
})
.store(in: &disposeBag)
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil)
.sink { [weak self] _ in
guard let self = self else { return }
self.player.seek(to: .zero)
self.playbackState.value = .stopped
self.currentTimeSubject.value = 0
}
.store(in: &disposeBag)
}
func isPlaying() -> Bool {
return self.playbackState.value == .readyToPlay || self.playbackState.value == .playing
}
func resume() {
player.play()
playbackState.value = .playing
}
func pause() {
player.pause()
playbackState.value = .paused
}
func seekToTime(time: TimeInterval) {
player.seek(to: CMTimeMake(value:Int64(time), timescale: 1))
}
}

View File

@ -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
}

View File

@ -23,7 +23,7 @@ extension Mastodon.Entity {
public let id: ID
public let type: Type
public let url: String
public let previewURL: String
public let previewURL: String? // could be nil when attachement is audio
public let remoteURL: String?
public let textURL: String?