From 091839c2e4203fdf8dc2cd54a95704ee20782eb6 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 8 Mar 2021 18:17:15 +0800 Subject: [PATCH 01/10] feat: add multipart helper. Add update credentials endpoint --- .../xcschemes/xcschememanagement.plist | 4 +- .../APIService/APIService+Account.swift | 33 +++ .../Mastodon+API+Account+Credentials.swift | 227 ++++++++++++++++++ .../API/Mastodon+API+Account.swift | 192 +-------------- .../MastodonSDK/API/Mastodon+API.swift | 9 +- .../Sources/MastodonSDK/Extension/Data.swift | 37 +++ .../Sources/MastodonSDK/Mastodon.swift | 1 + .../MediaAttachment.swift} | 20 +- .../Query/MultipartFormValue.swift | 37 +++ .../{Protocol => Query}/Query.swift | 20 +- .../API/MastodonSDK+API+AccountTests.swift | 17 +- 11 files changed, 391 insertions(+), 206 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Extension/Data.swift rename MastodonSDK/Sources/MastodonSDK/{Entity/Mastodon+Entity+MediaAttachment.swift => Query/MediaAttachment.swift} (66%) create mode 100644 MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift rename MastodonSDK/Sources/MastodonSDK/{Protocol => Query}/Query.swift (61%) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 60ccd3d87..6ec23cf5d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 7 + 10 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 8 + 7 SuppressBuildableAutocreation diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 6e26dbf83..2218bfa50 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -43,6 +43,39 @@ extension APIService { .eraseToAnyPublisher() } + func accountUpdateCredentials( + domain: String, + query: Mastodon.API.Account.UpdateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.updateCredentials( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + let account = response.value + + return self.backgroundManagedObjectContext.performChanges { + let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( + into: self.backgroundManagedObjectContext, + for: nil, + in: domain, + entity: account, + networkDate: response.networkDate, + log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + func accountRegister( domain: String, query: Mastodon.API.Account.RegisterQuery, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift new file mode 100644 index 000000000..04273188b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -0,0 +1,227 @@ +// +// Mastodon+API+Account+Credentials.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func accountsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") + } + + /// Register an account + /// + /// Creates a user and account records. + /// + /// - Since: 2.7.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `RegisterQuery` with account registration information + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func register( + session: URLSession, + domain: String, + query: RegisterQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: accountsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RegisterQuery: Codable, PostQuery { + public let reason: String? + public let username: String + public let email: String + public let password: String + public let agreement: Bool + public let locale: String + + public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { + self.reason = reason + self.username = username + self.email = email + self.password = password + self.agreement = agreement + self.locale = locale + } + } + +} + +extension Mastodon.API.Account { + + static func verifyCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") + } + + /// Verify account credentials + /// + /// Test to make sure that the user token works. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func verifyCredentials( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: verifyCredentialsEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + static func updateCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials") + } + + /// Update account credentials + /// + /// Update the user's display and preferences. + /// + /// - Since: 1.1.1 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `CredentialQuery` with update credential information + /// - authorization: user token + /// - Returns: `AnyPublisher` contains updated `Account` nested in the response + public static func updateCredentials( + session: URLSession, + domain: String, + query: UpdateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.patch( + url: updateCredentialsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UpdateCredentialQuery: PatchQuery { + public let discoverable: Bool? + public let bot: Bool? + public let displayName: String? + public let note: String? + public let avatar: Mastodon.Query.MediaAttachment? + public let header: Mastodon.Query.MediaAttachment? + public let locked: Bool? + public let source: Mastodon.Entity.Source? + public let fieldsAttributes: [Mastodon.Entity.Field]? + + enum CodingKeys: String, CodingKey { + case discoverable + case bot + case displayName = "display_name" + case note + + case avatar + case header + case locked + case source + case fieldsAttributes = "fields_attributes" + } + + public init( + discoverable: Bool? = nil, + bot: Bool? = nil, + displayName: String? = nil, + note: String? = nil, + avatar: Mastodon.Query.MediaAttachment? = nil, + header: Mastodon.Query.MediaAttachment? = nil, + locked: Bool? = nil, + source: Mastodon.Entity.Source? = nil, + fieldsAttributes: [Mastodon.Entity.Field]? = nil + ) { + self.discoverable = discoverable + self.bot = bot + self.displayName = displayName + self.note = note + self.avatar = avatar + self.header = header + self.locked = locked + self.source = source + self.fieldsAttributes = fieldsAttributes + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + discoverable.flatMap { data.append(Data.multipart(key: "discoverable", value: $0)) } + bot.flatMap { data.append(Data.multipart(key: "bot", value: $0)) } + displayName.flatMap { data.append(Data.multipart(key: "display_name", value: $0)) } + note.flatMap { data.append(Data.multipart(key: "note", value: $0)) } + avatar.flatMap { data.append(Data.multipart(key: "avatar", value: $0)) } + header.flatMap { data.append(Data.multipart(key: "header", value: $0)) } + locked.flatMap { data.append(Data.multipart(key: "locked", value: $0)) } + if let source = source { + source.privacy.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0.rawValue)) } + source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) } + source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) } + } + fieldsAttributes.flatMap { fieldsAttributes in + for fieldsAttribute in fieldsAttributes { + data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name)) + data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value)) + } + } + data.append(Data.multipartEnd()) + return data + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index d9b2a4448..3bb81dc6b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -8,119 +8,17 @@ import Foundation import Combine +// MARK: - Retrieve information extension Mastodon.API.Account { - - static func verifyCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") - } - static func accountsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") - } + static func accountsInfoEndpointURL(domain: String, id: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("accounts") .appendingPathComponent(id) } - static func updateCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials") - } - /// Test to make sure that the user token works. + /// Retrieve information /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response - public static func verifyCredentials( - session: URLSession, - domain: String, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.get( - url: verifyCredentialsEndpointURL(domain: domain), - query: nil, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Creates a user and account records. - /// - /// - Since: 2.7.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `RegisterQuery` with account registration information - /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func register( - session: URLSession, - domain: String, - query: RegisterQuery, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.post( - url: accountsEndpointURL(domain: domain), - query: query, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Update the user's display and preferences. - /// - /// - Since: 1.1.1 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `CredentialQuery` with update credential information - /// - authorization: user token - /// - Returns: `AnyPublisher` contains updated `Account` nested in the response - public static func updateCredentials( - session: URLSession, - domain: String, - query: UpdateCredentialQuery, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.patch( - url: updateCredentialsEndpointURL(domain: domain), - query: query, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - /// View information about a profile. /// /// - Since: 0.0.0 @@ -138,11 +36,11 @@ extension Mastodon.API.Account { public static func accountInfo( session: URLSession, domain: String, - query: AccountInfoQuery, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: accountsInfoEndpointURL(domain: domain, id: query.id), + url: accountsInfoEndpointURL(domain: domain, id: userID), query: nil, authorization: authorization ) @@ -155,79 +53,3 @@ extension Mastodon.API.Account { } } - -extension Mastodon.API.Account { - - public struct RegisterQuery: Codable, PostQuery { - public let reason: String? - public let username: String - public let email: String - public let password: String - public let agreement: Bool - public let locale: String - - public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { - self.reason = reason - self.username = username - self.email = email - self.password = password - self.agreement = agreement - self.locale = locale - } - } - - public struct UpdateCredentialQuery: Codable, PatchQuery { - - public var discoverable: Bool? - public var bot: Bool? - public var displayName: String? - public var note: String? - public var avatar: String? - public var header: String? - public var locked: Bool? - public var source: Mastodon.Entity.Source? - public var fieldsAttributes: [Mastodon.Entity.Field]? - - enum CodingKeys: String, CodingKey { - case discoverable - case bot - case displayName = "display_name" - case note - - case avatar - case header - case locked - case source - case fieldsAttributes = "fields_attributes" - } - - public init( - discoverable: Bool? = nil, - bot: Bool? = nil, - displayName: String? = nil, - note: String? = nil, - avatar: Mastodon.Entity.MediaAttachment? = nil, - header: Mastodon.Entity.MediaAttachment? = nil, - locked: Bool? = nil, - source: Mastodon.Entity.Source? = nil, - fieldsAttributes: [Mastodon.Entity.Field]? = nil - ) { - self.discoverable = discoverable - self.bot = bot - self.displayName = displayName - self.note = note - self.avatar = avatar?.base64EncondedString - self.header = header?.base64EncondedString - self.locked = locked - self.source = source - self.fieldsAttributes = fieldsAttributes - } - } - - public struct AccountInfoQuery: GetQuery { - - public let id: String - - var queryItems: [URLQueryItem]? { nil } - } -} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5a55ee103..073d926e9 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -140,8 +140,13 @@ extension Mastodon.API { timeoutInterval: Mastodon.API.timeoutInterval ) request.httpMethod = method.rawValue - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.httpBody = query?.body + if let contentType = query?.contentType { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + if let body = query?.body { + request.httpBody = body + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + } if let authorization = authorization { request.setValue( "Bearer \(authorization.accessToken)", diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift new file mode 100644 index 000000000..43354394d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift @@ -0,0 +1,37 @@ +// +// Data.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation + +extension Data { + + static func multipart( + boundary: String = Multipart.boundary, + key: String, + value: MultipartFormValue + ) -> Data { + var data = Data() + data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"\(key)\"".data(using: .utf8)!) + if let filename = value.multipartFilename { + data.append("; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + } else { + data.append("\r\n".data(using: .utf8)!) + } + if let contentType = value.multipartContentType { + data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!) + } + data.append("\r\n".data(using: .utf8)!) + data.append(value.multipartValue) + return data + } + + static func multipartEnd(boundary: String = Multipart.boundary) -> Data { + return "\r\n--\(boundary)--\r\n".data(using: .utf8)! + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Mastodon.swift b/MastodonSDK/Sources/MastodonSDK/Mastodon.swift index 0c5e90609..b64d3726e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Mastodon.swift +++ b/MastodonSDK/Sources/MastodonSDK/Mastodon.swift @@ -12,4 +12,5 @@ public enum Mastodon { public enum Response { } public enum API { } public enum Entity { } + public enum Query { } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift similarity index 66% rename from MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift rename to MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift index 39ffe23d7..f3bd88832 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entity+MediaAttachment.swift +// MediaAttachment.swift // // // Created by jk234ert on 2/9/21. @@ -7,7 +7,7 @@ import Foundation -extension Mastodon.Entity { +extension Mastodon.Query { public enum MediaAttachment { /// JPEG (Joint Photographic Experts Group) image case jpeg(Data?) @@ -20,7 +20,7 @@ extension Mastodon.Entity { } } -extension Mastodon.Entity.MediaAttachment { +extension Mastodon.Query.MediaAttachment { var data: Data? { switch self { case .jpeg(let data): return data @@ -31,11 +31,12 @@ extension Mastodon.Entity.MediaAttachment { } var fileName: String { + let name = UUID().uuidString switch self { - case .jpeg: return "file.jpg" - case .gif: return "file.gif" - case .png: return "file.png" - case .other(_, let fileExtension, _): return "file.\(fileExtension)" + case .jpeg: return "\(name).jpg" + case .gif: return "\(name).gif" + case .png: return "\(name).png" + case .other(_, let fileExtension, _): return "\(name).\(fileExtension)" } } @@ -53,3 +54,8 @@ extension Mastodon.Entity.MediaAttachment { } } +extension Mastodon.Query.MediaAttachment: MultipartFormValue { + var multipartValue: Data { return data ?? Data() } + var multipartContentType: String? { return mimeType } + var multipartFilename: String? { return fileName } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift new file mode 100644 index 000000000..fd9a9c8f4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift @@ -0,0 +1,37 @@ +// +// MultipartFormValue.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation + +enum Multipart { + static let boundary = "__boundary__" +} + +protocol MultipartFormValue { + var multipartValue: Data { get } + var multipartContentType: String? { get } + var multipartFilename: String? { get } +} + +extension Bool: MultipartFormValue { + var multipartValue: Data { + switch self { + case true: return "true".data(using: .utf8)! + case false: return "false".data(using: .utf8)! + } + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} + +extension String: MultipartFormValue { + var multipartValue: Data { + return self.data(using: .utf8)! + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift similarity index 61% rename from MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift rename to MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 7a6608d66..a0a5e4eae 100644 --- a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -14,12 +14,22 @@ enum RequestMethod: String { protocol RequestQuery { // All kinds of queries could have queryItems and body var queryItems: [URLQueryItem]? { get } + var contentType: String? { get } var body: Data? { get } } +extension RequestQuery { + static func multipartContentType(boundary: String = Multipart.boundary) -> String { + return "multipart/form-data; charset=utf-8; boundary=\"\(boundary)\"" + } +} + // An `Encodable` query provides its body by encoding itself // A `Get` query only contains queryItems, it should not be `Encodable` extension RequestQuery where Self: Encodable { + var contentType: String? { + return "application/json; charset=utf-8" + } var body: Data? { return try? Mastodon.API.encoder.encode(self) } @@ -30,18 +40,20 @@ protocol GetQuery: RequestQuery { } extension GetQuery { // By default a `GetQuery` does not has data body var body: Data? { nil } + var contentType: String? { nil } } -protocol PostQuery: RequestQuery & Encodable { } +protocol PostQuery: RequestQuery { } extension PostQuery { - // By default a `GetQuery` does not has query items + // By default a `PostQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } -protocol PatchQuery: RequestQuery & Encodable { } +protocol PatchQuery: RequestQuery { } extension PatchQuery { - // By default a `GetQuery` does not has query items + // By default a `PatchQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } + diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift index 08607aed8..b113672ec 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift @@ -8,9 +8,11 @@ import os.log import XCTest import Combine +import UIKit @testable import MastodonSDK extension MastodonSDKTests { + func testVerifyCredentials() throws { let theExpectation = expectation(description: "Verify Account Credentials") @@ -44,11 +46,14 @@ extension MastodonSDKTests { .flatMap({ (result) -> AnyPublisher, Error> in // TODO: replace with test account acct - XCTAssertEqual(result.value.acct, "") + XCTAssert(!result.value.acct.isEmpty) theExpectation1.fulfill() - - var query = Mastodon.API.Account.UpdateCredentialQuery() - query.note = dateString + + let query = Mastodon.API.Account.UpdateCredentialQuery( + bot: !(result.value.bot ?? false), + note: dateString, + header: Mastodon.Query.MediaAttachment.jpeg(UIImage(systemName: "house")!.jpegData(compressionQuality: 0.8)) + ) return Mastodon.API.Account.updateCredentials(session: self.session, domain: self.domain, query: query, authorization: authorization) }) .sink { completion in @@ -73,8 +78,7 @@ extension MastodonSDKTests { func testRetrieveAccountInfo() throws { let theExpectation = expectation(description: "Verify Account Credentials") - let query = Mastodon.API.Account.AccountInfoQuery(id: "1") - Mastodon.API.Account.accountInfo(session: session, domain: domain, query: query, authorization: nil) + Mastodon.API.Account.accountInfo(session: session, domain: "mastodon.online", userID: "1", authorization: nil) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -91,4 +95,5 @@ extension MastodonSDKTests { wait(for: [theExpectation], timeout: 5.0) } + } From 1a66ba92c0a38c68795b40ab7ed07a365b771fb9 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 8 Mar 2021 19:05:15 +0800 Subject: [PATCH 02/10] feat: add avatar and display name update logic after sign-up flow --- .../MastodonConfirmEmailViewController.swift | 19 ++++++++++++++- .../MastodonConfirmEmailViewModel.swift | 11 ++++++++- .../MastodonRegisterViewController.swift | 24 +++++++++++++++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 2d69f0dd3..dee1510cd 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -111,7 +111,24 @@ extension MastodonConfirmEmailViewController { case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: - break + // upload avatar and set display name in the background + self.context.apiService.accountUpdateCredentials( + domain: self.viewModel.authenticateInfo.domain, + query: self.viewModel.updateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization(accessToken: self.viewModel.userToken.accessToken) + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) // execute in the background } } receiveValue: { response in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s's email confirmed", ((#file as NSString).lastPathComponent), #line, #function, response.value.username) diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift index aff254741..9fbd24eda 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift @@ -12,20 +12,29 @@ import MastodonSDK final class MastodonConfirmEmailViewModel { var disposeBag = Set() + // input let context: AppContext var email: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let userToken: Mastodon.Entity.Token + let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery let timestampUpdatePublisher = Timer.publish(every: 4.0, on: .main, in: .common) .autoconnect() .share() .eraseToAnyPublisher() - init(context: AppContext, email: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, userToken: Mastodon.Entity.Token) { + init( + context: AppContext, + email: String, + authenticateInfo: AuthenticationViewModel.AuthenticateInfo, + userToken: Mastodon.Entity.Token, + updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery + ) { self.context = context self.email = email self.authenticateInfo = authenticateInfo self.userToken = userToken + self.updateCredentialQuery = updateCredentialQuery } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index d1ef11c67..f078e9b8d 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -5,12 +5,12 @@ // Created by MainasuK Cirno on 2021-2-5. // +import AlamofireImage import Combine import MastodonSDK import os.log import PhotosUI import UIKit -import UITextField_Shake final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { var disposeBag = Set() @@ -623,10 +623,10 @@ extension MastodonRegisterViewController { username: username, email: email, password: password, - agreement: true, // TODO: - locale: "en" // TODO: + agreement: true, // user confirmed in the server rules scene + locale: Locale.current.languageCode ?? "en" ) - + // register without show server rules context.apiService.accountRegister( domain: viewModel.domain, @@ -646,7 +646,21 @@ extension MastodonRegisterViewController { } receiveValue: { [weak self] response in guard let self = self else { return } let userToken = response.value - let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) + let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = { + let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value + let avatar: Mastodon.Query.MediaAttachment? = { + guard let avatarImage = self.viewModel.avatarImage.value else { return nil } + guard avatarImage.size.width <= 400 else { + return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8)) + } + return .jpeg(avatarImage.jpegData(compressionQuality: 0.8)) + }() + return Mastodon.API.Account.UpdateCredentialQuery( + displayName: displayName, + avatar: avatar + ) + }() + let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery) self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) } .store(in: &disposeBag) From 8bce19713665603ddf33fbae07b2024b3a8aecb1 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 8 Mar 2021 11:21:34 +0800 Subject: [PATCH 03/10] chore: make media_attachments.preview_url optional It's null when toot has audio So the document is wrong --- CoreDataStack/Entity/Attachment.swift | 6 +++--- Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift | 3 ++- .../MastodonSDK/Entity/Mastodon+Entity+Attachment.swift | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index e580014c1..33a0c0826 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -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?, diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index aa9d79c73..ce92ccb77 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -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, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift index 2a09ccfc8..bb03ba1bd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -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? public let remoteURL: String? public let textURL: String? From 30d03a38945b168bda8b2c73ebbd746ddd7da79e Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 8 Mar 2021 11:42:10 +0800 Subject: [PATCH 04/10] 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 3f8fe73f9..be40ac57d 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 b3abfd84a..1653f6241 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 5f9d43ed5..489b9d3b8 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 000000000..f485ec2d9 --- /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 000000000..792e82508 --- /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 000000000..e821b676c --- /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 32786b40d..f68170460 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 000000000..6e965652d --- /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 000000000..dc91052f7 --- /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 000000000..cddd7871b --- /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 c1f3cb3d0..2713647fe 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 000000000..ce8d61def --- /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 000000000..4a14a080b --- /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 000000000..75fced7bb --- /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 +} From 2d4dbad5350ae52dc07fbc4abd8616b34cfa3c62 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 15:18:36 +0800 Subject: [PATCH 05/10] chore: fix slider shake, reset audioView when stoped --- .../View/Container/AudioContainerView.swift | 3 ++ .../ViewModel/AudioContainerViewModel.swift | 10 ++-- Mastodon/Service/AudioPlayer.swift | 48 +++++++++++-------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index cddd7871b..e20f5cca0 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -109,6 +109,9 @@ extension AudioContainerView { container.addArrangedSubview(slider) container.addArrangedSubview(timeLabel) + NSLayoutConstraint.activate([ + timeLabel.widthAnchor.constraint(equalToConstant: 40), + ]) } } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index ce8d61def..8d4e9e2a5 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -66,12 +66,14 @@ class AudioContainerViewModel { }) .store(in: &cell.disposeBag) AudioPlayer.shared.playbackState - .map { - return $0 == .playing || $0 == .readyToPlay - } - .sink(receiveValue: { isPlaying in + .receive(on: DispatchQueue.main) + .sink(receiveValue: { playbackState in if (audioAttachment === AudioPlayer.shared.attachment) { + let isPlaying = playbackState == .playing || playbackState == .readyToPlay audioView.playButton.isSelected = isPlaying + if playbackState == .stopped { + self.resetAudioView(audioView: audioView) + } } else { self.resetAudioView(audioView: audioView) } diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 4a14a080b..95be2e78a 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -57,14 +57,34 @@ extension AudioPlayer { func addObserver() { UIDevice.current.isProximityMonitoringEnabled = true - NotificationCenter.default.addObserver(self, selector: #selector(proxumityStateChange), name: UIDevice.proximityStateDidChangeNotification, object: nil) - + 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: { status in + .sink(receiveValue: { [weak self] status in + guard let self = self else { return } switch status { case .failed: self.playbackState.value = .failed @@ -77,25 +97,13 @@ extension AudioPlayer { } }) .store(in: &disposeBag) + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) + .sink { _ in + self.playbackState.send(PlaybackState.stopped) + } + .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() From 5a17b8a6eebd8ac50957adc05c5c23125a2666a4 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 16:25:47 +0800 Subject: [PATCH 06/10] chore: make slider enable state change with isPlaying --- Mastodon.xcodeproj/project.pbxproj | 4 - Mastodon/Extension/UIIamge.swift | 55 ------------- Mastodon/Extension/UIImage.swift | 77 ++++++++++++++----- .../View/Container/AudioContainerView.swift | 30 +++----- .../ViewModel/AudioContainerViewModel.swift | 22 +++--- .../Entity/Mastodon+Entity+Attachment.swift | 2 +- 6 files changed, 82 insertions(+), 108 deletions(-) delete mode 100644 Mastodon/Extension/UIIamge.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 1653f6241..f2db77a66 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -49,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 */; }; @@ -288,7 +287,6 @@ 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = ""; }; @@ -1101,7 +1099,6 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, @@ -1614,7 +1611,6 @@ 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 */, diff --git a/Mastodon/Extension/UIIamge.swift b/Mastodon/Extension/UIIamge.swift deleted file mode 100644 index 4f4b350c3..000000000 --- a/Mastodon/Extension/UIIamge.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// UIIamge.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/28. -// - -import UIKit -import CoreImage -import CoreImage.CIFilterBuiltins - -extension UIImage { - - static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage { - let render = UIGraphicsImageRenderer(size: size) - - return render.image { (context: UIGraphicsImageRendererContext) in - context.cgContext.setFillColor(color.cgColor) - 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 -extension UIImage { - @available(iOS 14.0, *) - var dominantColor: UIColor? { - guard let inputImage = CIImage(image: self) else { return nil } - - let filter = CIFilter.areaAverage() - filter.inputImage = inputImage - filter.extent = inputImage.extent - guard let outputImage = filter.outputImage else { return nil } - - var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [.workingColorSpace: kCFNull]) - context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - - return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) - } -} - -extension UIImage { - func blur(radius: CGFloat) -> UIImage? { - guard let inputImage = CIImage(image: self) else { return nil } - let blurFilter = CIFilter.gaussianBlur() - blurFilter.inputImage = inputImage - blurFilter.radius = Float(radius) - guard let outputImage = blurFilter.outputImage else { return nil } - guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil } - let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) - return image - } -} diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift index e821b676c..3c3c43400 100644 --- a/Mastodon/Extension/UIImage.swift +++ b/Mastodon/Extension/UIImage.swift @@ -5,31 +5,66 @@ // Created by sxiaojian on 2021/3/8. // +import CoreImage +import CoreImage.CIFilterBuiltins 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 + static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage { + let render = UIGraphicsImageRenderer(size: size) + + return render.image { (context: UIGraphicsImageRendererContext) in + context.cgContext.setFillColor(color.cgColor) + context.fill(CGRect(origin: .zero, size: size)) } - UIGraphicsBeginImageContextWithOptions(size, false, scale) - let rect = CGRect(origin: .zero, size: size) - UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() - draw(in: rect) - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() + } +} + +// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage +extension UIImage { + @available(iOS 14.0, *) + var dominantColor: UIColor? { + guard let inputImage = CIImage(image: self) else { return nil } + + let filter = CIFilter.areaAverage() + filter.inputImage = inputImage + filter.extent = inputImage.extent + guard let outputImage = filter.outputImage else { return nil } + + var bitmap = [UInt8](repeating: 0, count: 4) + let context = CIContext(options: [.workingColorSpace: kCFNull]) + context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) + + return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) + } +} + +extension UIImage { + func blur(radius: CGFloat) -> UIImage? { + guard let inputImage = CIImage(image: self) else { return nil } + let blurFilter = CIFilter.gaussianBlur() + blurFilter.inputImage = inputImage + blurFilter.radius = Float(radius) + guard let outputImage = blurFilter.outputImage else { return nil } + guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil } + let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) 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) + } + } +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index e20f5cca0..980e5ae87 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -5,13 +5,11 @@ // Created by sxiaojian on 2021/3/8. // -import os.log import CoreDataStack +import os.log import UIKit - final class AudioContainerView: UIView { - static let cornerRadius: CGFloat = 22 let container: UIStackView = { @@ -20,7 +18,7 @@ final class AudioContainerView: UIView { stackView.distribution = .fill stackView.alignment = .center stackView.spacing = 11 - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 9, bottom: 0, right: 9) + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) stackView.isLayoutMarginsRelativeArrangement = true stackView.layer.cornerRadius = AudioContainerView.cornerRadius stackView.clipsToBounds = true @@ -29,7 +27,7 @@ final class AudioContainerView: UIView { return stackView }() - let checkmarkBackgroundView: UIView = { + let playButtonBackgroundView: UIView = { let view = UIView() view.layer.cornerRadius = 16 view.clipsToBounds = true @@ -39,7 +37,7 @@ final class AudioContainerView: UIView { }() let playButton: UIButton = { - let button = UIButton(type: .custom) + 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) @@ -57,7 +55,7 @@ final class AudioContainerView: UIView { 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) { + if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) { slider.setThumbImage(image, for: .normal) } return slider @@ -81,13 +79,10 @@ final class AudioContainerView: UIView { super.init(coder: coder) _init() } - } extension AudioContainerView { - private func _init() { - addSubview(container) NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: topAnchor), @@ -96,14 +91,14 @@ extension AudioContainerView { bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) - //checkmark - checkmarkBackgroundView.addSubview(playButton) - container.addArrangedSubview(checkmarkBackgroundView) + // checkmark + playButtonBackgroundView.addSubview(playButton) + container.addArrangedSubview(playButtonBackgroundView) NSLayoutConstraint.activate([ - playButton.centerXAnchor.constraint(equalTo: checkmarkBackgroundView.centerXAnchor), - playButton.centerYAnchor.constraint(equalTo: checkmarkBackgroundView.centerYAnchor), - checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: 32), - checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: 32), + 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) @@ -113,5 +108,4 @@ extension AudioContainerView { timeLabel.widthAnchor.constraint(equalToConstant: 40), ]) } - } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 8d4e9e2a5..de250a539 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -5,8 +5,8 @@ // Created by sxiaojian on 2021/3/9. // -import Foundation import CoreDataStack +import Foundation import UIKit class AudioContainerViewModel { @@ -17,10 +17,11 @@ class AudioContainerViewModel { 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) { + .sink { _ in + let isPlaying = AudioPlayer.shared.playbackState.value == .readyToPlay || AudioPlayer.shared.playbackState.value == .playing + if isPlaying { AudioPlayer.shared.pause() } else { if audioAttachment === AudioPlayer.shared.attachment { @@ -39,15 +40,15 @@ class AudioContainerViewModel { .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) + self.observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { self.resetAudioView(audioView: audioView) } } + static func observePlayer( cell: StatusTableViewCell, audioAttachment: Attachment @@ -61,16 +62,17 @@ class AudioContainerViewModel { .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) + audioView.slider.setValue(Float(time / duration), animated: true) } }) .store(in: &cell.disposeBag) AudioPlayer.shared.playbackState .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in - if (audioAttachment === AudioPlayer.shared.attachment) { + if audioAttachment === AudioPlayer.shared.attachment { let isPlaying = playbackState == .playing || playbackState == .readyToPlay audioView.playButton.isSelected = isPlaying + audioView.slider.isEnabled = isPlaying if playbackState == .stopped { self.resetAudioView(audioView: audioView) } @@ -80,8 +82,10 @@ class AudioContainerViewModel { }) .store(in: &cell.disposeBag) } - static func resetAudioView(audioView:AudioContainerView) { + + static func resetAudioView(audioView: AudioContainerView) { audioView.playButton.isSelected = false audioView.slider.setValue(0, animated: false) + audioView.slider.isEnabled = false } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift index bb03ba1bd..d50d87695 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -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? From 0ae89aff9fd449da2d6f48b1028c517a74985d16 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 16:54:05 +0800 Subject: [PATCH 07/10] fix: can't play audio again when stoped --- .../ViewModel/AudioContainerViewModel.swift | 5 ++--- Mastodon/Service/AudioPlayer.swift | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index de250a539..303eae2a7 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -20,8 +20,7 @@ class AudioContainerViewModel { audioView.playButton.publisher(for: .touchUpInside) .sink { _ in - let isPlaying = AudioPlayer.shared.playbackState.value == .readyToPlay || AudioPlayer.shared.playbackState.value == .playing - if isPlaying { + if AudioPlayer.shared.isPlaying() { AudioPlayer.shared.pause() } else { if audioAttachment === AudioPlayer.shared.attachment { @@ -70,7 +69,7 @@ class AudioContainerViewModel { .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in if audioAttachment === AudioPlayer.shared.attachment { - let isPlaying = playbackState == .playing || playbackState == .readyToPlay + let isPlaying = AudioPlayer.shared.isPlaying() audioView.playButton.isSelected = isPlaying audioView.slider.isEnabled = isPlaying if playbackState == .stopped { diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 95be2e78a..15168c761 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -44,7 +44,11 @@ extension AudioPlayer { } if audioAttachment == attachment { + if self.playbackState.value == .stopped { + self.seekToTime(time: 0) + } player.play() + self.playbackState.value = .playing return } @@ -52,7 +56,7 @@ extension AudioPlayer { player.replaceCurrentItem(with: playerItem) attachment = audioAttachment player.play() - playbackState.send(PlaybackState.playing) + playbackState.value = .playing } func addObserver() { @@ -99,20 +103,23 @@ extension AudioPlayer { .store(in: &disposeBag) NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) .sink { _ in - self.playbackState.send(PlaybackState.stopped) + 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.send(PlaybackState.playing) + playbackState.value = .playing } func pause() { player.pause() - playbackState.send(PlaybackState.paused) + playbackState.value = .paused } func seekToTime(time: TimeInterval) { From 2b02b8deb65cfd3e6e16c45fb611f8d4c558b3e7 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 17:13:17 +0800 Subject: [PATCH 08/10] fix: play audio between two toots --- .../ViewModel/AudioContainerViewModel.swift | 31 +++++++++++-------- Mastodon/Service/AudioPlayer.swift | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 303eae2a7..510df76bc 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -20,18 +20,18 @@ class AudioContainerViewModel { audioView.playButton.publisher(for: .touchUpInside) .sink { _ in - if AudioPlayer.shared.isPlaying() { - AudioPlayer.shared.pause() - } else { - if audioAttachment === AudioPlayer.shared.attachment { - if AudioPlayer.shared.currentTimeSubject.value == 0 { - AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) - } else { - AudioPlayer.shared.resume() - } + + 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) @@ -44,7 +44,7 @@ class AudioContainerViewModel { .store(in: &cell.disposeBag) self.observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { - self.resetAudioView(audioView: audioView) + self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) } } @@ -73,18 +73,23 @@ class AudioContainerViewModel { audioView.playButton.isSelected = isPlaying audioView.slider.isEnabled = isPlaying if playbackState == .stopped { - self.resetAudioView(audioView: audioView) + self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) } } else { - self.resetAudioView(audioView: audioView) + self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) } }) .store(in: &cell.disposeBag) } - static func resetAudioView(audioView: AudioContainerView) { + static func resetAudioView( + audioView: AudioContainerView, + audioAttachment: Attachment + ) { audioView.playButton.isSelected = false audioView.slider.setValue(0, animated: false) audioView.slider.isEnabled = false + guard let duration = audioAttachment.meta?.original?.duration else { return } + audioView.timeLabel.text = duration.asString(style: .positional) } } diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 15168c761..458ca7614 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -51,7 +51,7 @@ extension AudioPlayer { self.playbackState.value = .playing return } - + player.pause() let playerItem = AVPlayerItem(url: url) player.replaceCurrentItem(with: playerItem) attachment = audioAttachment From fd4e99907b2c1d11021e64d83f6008eb7fb8f982 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 18:54:21 +0800 Subject: [PATCH 09/10] fix: slider jumping after drag issue. Fix player can not play again issue --- .../ViewModel/AudioContainerViewModel.swift | 27 ++++++++++++------- Mastodon/Service/AudioPlayer.swift | 14 ++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 510df76bc..cb1603404 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -20,7 +20,6 @@ class AudioContainerViewModel { audioView.playButton.publisher(for: .touchUpInside) .sink { _ in - if audioAttachment === AudioPlayer.shared.attachment { if AudioPlayer.shared.isPlaying() { AudioPlayer.shared.pause() @@ -53,16 +52,26 @@ class AudioContainerViewModel { audioAttachment: Attachment ) { let audioView = cell.statusView.audioView + var lastCurrentTimeSubject: TimeInterval? 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) + .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 diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 458ca7614..13479f0af 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -18,14 +18,16 @@ final class AudioPlayer: NSObject { var timeObserver: Any? var statusObserver: Any? var attachment: Attachment? - var currentURL: URL? + let session = AVAudioSession.sharedInstance() let playbackState = CurrentValueSubject(PlaybackState.unknown) + + // MARK: - singleton public static let shared = AudioPlayer() let currentTimeSubject = CurrentValueSubject(0) - override init() { + private override init() { super.init() addObserver() } @@ -45,7 +47,7 @@ extension AudioPlayer { if audioAttachment == attachment { if self.playbackState.value == .stopped { - self.seekToTime(time: 0) + self.seekToTime(time: .zero) } player.play() self.playbackState.value = .playing @@ -97,12 +99,14 @@ extension AudioPlayer { case .unknown: self.playbackState.value = .unknown @unknown default: - fatalError() + assertionFailure() } }) .store(in: &disposeBag) NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) - .sink { _ in + .sink { [weak self] _ in + guard let self = self else { return } + self.player.seek(to: .zero) self.playbackState.value = .stopped self.currentTimeSubject.value = 0 } From defb0ae6e06753f82ff087253756fe919a9589aa Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 19:07:30 +0800 Subject: [PATCH 10/10] feat: make play button reflect with state change --- .../ViewModel/AudioContainerViewModel.swift | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index cb1603404..70509d40e 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -43,7 +43,7 @@ class AudioContainerViewModel { .store(in: &cell.disposeBag) self.observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { - self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } } @@ -78,26 +78,33 @@ class AudioContainerViewModel { .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in if audioAttachment === AudioPlayer.shared.attachment { - let isPlaying = AudioPlayer.shared.isPlaying() - audioView.playButton.isSelected = isPlaying - audioView.slider.isEnabled = isPlaying - if playbackState == .stopped { - self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) - } + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState) } else { - self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } }) .store(in: &cell.disposeBag) } - static func resetAudioView( + static func configureAudioView( audioView: AudioContainerView, - audioAttachment: Attachment + audioAttachment: Attachment, + playbackState: PlaybackState ) { - audioView.playButton.isSelected = false - audioView.slider.setValue(0, animated: false) - audioView.slider.isEnabled = false + 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) }