Merge tag '0.7.7' into develop
no message
This commit is contained in:
commit
ebf779a5e7
@ -3843,7 +3843,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
@ -3851,7 +3851,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -3870,7 +3870,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
@ -3878,7 +3878,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -4198,7 +4198,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
@ -4206,7 +4206,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -4312,7 +4312,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -4320,7 +4320,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@ -4431,7 +4431,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
@ -4439,7 +4439,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -4545,7 +4545,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -4553,7 +4553,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@ -4599,7 +4599,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -4607,7 +4607,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@ -4622,7 +4622,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -4630,7 +4630,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.6;
|
||||
MARKETING_VERSION = 0.7.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@ -4778,7 +4778,7 @@
|
||||
repositoryURL = "https://github.com/TwidereProject/MetaTextView.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 1.2.2;
|
||||
version = 1.2.3;
|
||||
};
|
||||
};
|
||||
DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = {
|
||||
|
@ -114,8 +114,8 @@
|
||||
"repositoryURL": "https://github.com/TwidereProject/MetaTextView.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "d48cf6a2479ce6fc4f836b6c4d7ba855cdbc71cc",
|
||||
"version": "1.2.2"
|
||||
"revision": "5b86b386464be8a6da5383aa714c458c07da6c01",
|
||||
"version": "1.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -58,14 +58,8 @@ extension ComposeStatusSection {
|
||||
|
||||
}
|
||||
|
||||
protocol CustomEmojiReplaceableTextInput: AnyObject {
|
||||
protocol CustomEmojiReplaceableTextInput: UITextInput & UIResponder {
|
||||
var inputView: UIView? { get set }
|
||||
func reloadInputViews()
|
||||
|
||||
// UIKeyInput
|
||||
func insertText(_ text: String)
|
||||
// UIResponder
|
||||
var isFirstResponder: Bool { get }
|
||||
}
|
||||
|
||||
class CustomEmojiReplaceableTextInputReference {
|
||||
|
@ -953,10 +953,15 @@ extension StatusSection {
|
||||
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue)
|
||||
}()
|
||||
|
||||
// disable reblog when non-public (except self)
|
||||
// disable reblog if needs (except self)
|
||||
cell.statusView.actionToolbarContainer.reblogButton.isEnabled = true
|
||||
if let visibility = status.visibilityEnum, visibility != .public, status.author.id != requestUserID {
|
||||
cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false
|
||||
if let visibility = status.visibilityEnum, status.author.id != requestUserID {
|
||||
switch visibility {
|
||||
case .public, .unlisted:
|
||||
break
|
||||
default:
|
||||
cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// set like
|
||||
|
@ -29,6 +29,23 @@ extension UserProviderFacade {
|
||||
mastodonUser: provider.mastodonUser().eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
|
||||
static func toggleUserFollowRelationship(
|
||||
provider: UserProvider,
|
||||
mastodonUser: MastodonUser
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
|
||||
// prepare authentication
|
||||
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return _toggleUserFollowRelationship(
|
||||
context: provider.context,
|
||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
mastodonUser: Just(mastodonUser).eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
|
||||
private static func _toggleUserFollowRelationship(
|
||||
context: AppContext,
|
||||
@ -52,6 +69,22 @@ extension UserProviderFacade {
|
||||
}
|
||||
|
||||
extension UserProviderFacade {
|
||||
static func toggleUserBlockRelationship(
|
||||
provider: UserProvider,
|
||||
mastodonUser: MastodonUser
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
|
||||
// prepare authentication
|
||||
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
|
||||
}
|
||||
return _toggleUserBlockRelationship(
|
||||
context: provider.context,
|
||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
mastodonUser: Just(mastodonUser).eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
|
||||
static func toggleUserBlockRelationship(
|
||||
provider: UserProvider,
|
||||
cell: UITableViewCell?
|
||||
@ -98,6 +131,23 @@ extension UserProviderFacade {
|
||||
}
|
||||
|
||||
extension UserProviderFacade {
|
||||
|
||||
static func toggleUserMuteRelationship(
|
||||
provider: UserProvider,
|
||||
mastodonUser: MastodonUser
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
|
||||
// prepare authentication
|
||||
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
|
||||
}
|
||||
return _toggleUserMuteRelationship(
|
||||
context: provider.context,
|
||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
mastodonUser: Just(mastodonUser).eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
|
||||
static func toggleUserMuteRelationship(
|
||||
provider: UserProvider,
|
||||
cell: UITableViewCell?
|
||||
|
@ -236,6 +236,10 @@ extension ComposeViewModel: UITableViewDataSource {
|
||||
}
|
||||
// configure author
|
||||
ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute)
|
||||
// configure content. bind text in UITextViewDelegate
|
||||
if let composeContent = composeStatusAttribute.composeContent.value {
|
||||
cell.metaText.textView.text = composeContent
|
||||
}
|
||||
// configure content warning
|
||||
cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent.value
|
||||
// bind content warning
|
||||
@ -254,7 +258,6 @@ extension ComposeViewModel: UITableViewDataSource {
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
cell.contentWarningContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -292,33 +292,32 @@ final class ComposeViewModel: NSObject {
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// setup attribute updater
|
||||
Publishers.CombineLatest(
|
||||
attachmentServices,
|
||||
context.timestampUpdatePublisher
|
||||
)
|
||||
.sink { attachmentServices, _ in
|
||||
// drive service upload state
|
||||
// make image upload in the queue
|
||||
for attachmentService in attachmentServices {
|
||||
// skip when prefix N task when task finish OR fail OR uploading
|
||||
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
|
||||
if currentState is MastodonAttachmentService.UploadState.Fail {
|
||||
continue
|
||||
}
|
||||
if currentState is MastodonAttachmentService.UploadState.Finish {
|
||||
continue
|
||||
}
|
||||
if currentState is MastodonAttachmentService.UploadState.Uploading {
|
||||
break
|
||||
}
|
||||
// trigger uploading one by one
|
||||
if currentState is MastodonAttachmentService.UploadState.Initial {
|
||||
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
|
||||
break
|
||||
attachmentServices
|
||||
.receive(on: DispatchQueue.main)
|
||||
.debounce(for: 0.3, scheduler: DispatchQueue.main)
|
||||
.sink { attachmentServices in
|
||||
// drive service upload state
|
||||
// make image upload in the queue
|
||||
for attachmentService in attachmentServices {
|
||||
// skip when prefix N task when task finish OR fail OR uploading
|
||||
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
|
||||
if currentState is MastodonAttachmentService.UploadState.Fail {
|
||||
continue
|
||||
}
|
||||
if currentState is MastodonAttachmentService.UploadState.Finish {
|
||||
continue
|
||||
}
|
||||
if currentState is MastodonAttachmentService.UploadState.Uploading {
|
||||
break
|
||||
}
|
||||
// trigger uploading one by one
|
||||
if currentState is MastodonAttachmentService.UploadState.Initial {
|
||||
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind delegate
|
||||
attachmentServices
|
||||
|
@ -93,9 +93,8 @@ extension ComposeStatusAttachmentTableViewCell {
|
||||
cell.attachmentContainerView.previewImageView.image = placeholder
|
||||
return
|
||||
}
|
||||
// cannot get correct size. set corner radius on layer
|
||||
cell.attachmentContainerView.previewImageView.image = image
|
||||
.af.imageAspectScaled(toFill: size)
|
||||
.af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
Publishers.CombineLatest(
|
||||
|
@ -38,6 +38,21 @@ final class ComposeStatusContentTableViewCell: UITableViewCell {
|
||||
attributes: attributes
|
||||
)
|
||||
}()
|
||||
let paragraphStyle: NSMutableParagraphStyle = {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = 5
|
||||
return style
|
||||
}()
|
||||
metaText.textAttributes = [
|
||||
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
|
||||
.foregroundColor: Asset.Colors.Label.primary.color,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
metaText.linkAttributes = [
|
||||
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
|
||||
.foregroundColor: Asset.Colors.brandBlue.color,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
return metaText
|
||||
}()
|
||||
|
||||
@ -68,6 +83,7 @@ extension ComposeStatusContentTableViewCell {
|
||||
private func _init() {
|
||||
selectionStyle = .none
|
||||
layer.zPosition = 999
|
||||
backgroundColor = .clear
|
||||
preservesSuperviewLayoutMargins = true
|
||||
|
||||
let containerStackView = UIStackView()
|
||||
|
@ -19,6 +19,8 @@ final class AttachmentContainerView: UIView {
|
||||
let previewImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.layer.masksToBounds = true
|
||||
return imageView
|
||||
}()
|
||||
|
@ -46,8 +46,23 @@ extension CustomEmojiPickerInputViewModel {
|
||||
removeEmptyReferences()
|
||||
|
||||
for reference in customEmojiReplaceableTextInputReferences {
|
||||
guard reference.value?.isFirstResponder == true else { continue }
|
||||
reference.value?.insertText(text)
|
||||
guard let textInput = reference.value else { continue }
|
||||
guard textInput.isFirstResponder == true else { continue }
|
||||
|
||||
let selectedTextRange = textInput.selectedTextRange
|
||||
textInput.insertText(text)
|
||||
|
||||
// due to insert text render as attachment
|
||||
// the cursor reset logic not works
|
||||
// hack with hard code +2 offset
|
||||
assert(text.hasSuffix(": "))
|
||||
if text.hasPrefix(":") && text.hasSuffix(": "),
|
||||
let selectedTextRange = selectedTextRange,
|
||||
let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) {
|
||||
let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition)
|
||||
textInput.selectedTextRange = newSelectedTextRange
|
||||
}
|
||||
|
||||
return reference
|
||||
}
|
||||
|
||||
|
@ -93,6 +93,7 @@ extension NotificationViewController {
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.viewModel.needsScrollToTopAfterDataSourceUpdate else { return }
|
||||
self.viewModel.needsScrollToTopAfterDataSourceUpdate = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) {
|
||||
self.scrollToTop(animated: true)
|
||||
@ -106,6 +107,9 @@ extension NotificationViewController {
|
||||
.sink { [weak self] segment in
|
||||
guard let self = self else { return }
|
||||
self.segmentControl.selectedSegmentIndex = segment.rawValue
|
||||
|
||||
// trigger scroll-to-top after data reload
|
||||
self.viewModel.needsScrollToTopAfterDataSourceUpdate = true
|
||||
|
||||
guard let domain = self.viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = self.viewModel.activeMastodonAuthenticationBox.value?.userID else {
|
||||
return
|
||||
|
@ -20,14 +20,13 @@ extension SearchViewController: UserProvider {
|
||||
|
||||
func mastodonUser() -> Future<MastodonUser?, Never> {
|
||||
Future { promise in
|
||||
promise(.success(self.viewModel.mastodonUser.value))
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate {
|
||||
func followButtonDidPressed(clickedUser: MastodonUser) {
|
||||
viewModel.mastodonUser.value = clickedUser
|
||||
guard let currentMastodonUser = viewModel.currentMastodonUser.value else {
|
||||
return
|
||||
}
|
||||
@ -36,17 +35,17 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
|
||||
case .none:
|
||||
break
|
||||
case .follow, .following:
|
||||
UserProviderFacade.toggleUserFollowRelationship(provider: self)
|
||||
UserProviderFacade.toggleUserFollowRelationship(provider: self, mastodonUser: clickedUser)
|
||||
.sink { _ in
|
||||
|
||||
// error handling
|
||||
} receiveValue: { _ in
|
||||
// success
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
case .pending:
|
||||
break
|
||||
case .muting:
|
||||
guard let mastodonUser = viewModel.mastodonUser.value else { return }
|
||||
let name = mastodonUser.displayNameWithFallback
|
||||
let name = clickedUser.displayNameWithFallback
|
||||
let alertController = UIAlertController(
|
||||
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title,
|
||||
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name),
|
||||
@ -54,7 +53,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
|
||||
)
|
||||
let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil)
|
||||
UserProviderFacade.toggleUserMuteRelationship(provider: self, mastodonUser: clickedUser)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { _ in
|
||||
@ -67,8 +66,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
|
||||
alertController.addAction(cancelAction)
|
||||
present(alertController, animated: true, completion: nil)
|
||||
case .blocking:
|
||||
guard let mastodonUser = viewModel.mastodonUser.value else { return }
|
||||
let name = mastodonUser.displayNameWithFallback
|
||||
let name = clickedUser.displayNameWithFallback
|
||||
let alertController = UIAlertController(
|
||||
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title,
|
||||
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name),
|
||||
@ -76,7 +74,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
|
||||
)
|
||||
let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil)
|
||||
UserProviderFacade.toggleUserBlockRelationship(provider: self, mastodonUser: clickedUser)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { _ in
|
||||
|
@ -21,7 +21,6 @@ final class SearchViewModel: NSObject {
|
||||
let context: AppContext
|
||||
weak var coordinator: SceneCoordinator!
|
||||
|
||||
let mastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||
let viewDidAppeared = PassthroughSubject<Void, Never>()
|
||||
|
||||
@ -33,7 +32,7 @@ final class SearchViewModel: NSObject {
|
||||
|
||||
let searchResult = CurrentValueSubject<Mastodon.Entity.SearchResult?, Never>(nil)
|
||||
|
||||
var recommendHashTags = [Mastodon.Entity.Tag]()
|
||||
// var recommendHashTags = [Mastodon.Entity.Tag]()
|
||||
var recommendAccounts = [NSManagedObjectID]()
|
||||
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
|
||||
|
||||
@ -61,11 +60,7 @@ final class SearchViewModel: NSObject {
|
||||
self.coordinator = coordinator
|
||||
self.context = context
|
||||
super.init()
|
||||
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// bind active authentication
|
||||
context.authenticationService.activeMastodonAuthentication
|
||||
.sink { [weak self] activeMastodonAuthentication in
|
||||
@ -86,26 +81,43 @@ final class SearchViewModel: NSObject {
|
||||
.filter { text, _ in
|
||||
!text.isEmpty
|
||||
}
|
||||
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
||||
|
||||
let query = Mastodon.API.V2.Search.Query(q: text,
|
||||
type: scope,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: nil,
|
||||
following: nil)
|
||||
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.compactMap { (text, scope) -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error>, Never>? in
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
|
||||
let query = Mastodon.API.V2.Search.Query(
|
||||
q: text,
|
||||
type: scope,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: nil,
|
||||
following: nil
|
||||
)
|
||||
return context.apiService.search(
|
||||
domain: activeMastodonAuthenticationBox.domain,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
)
|
||||
// .retry(3) // iOS 14.0 SDK may not works here. needs testing before add this
|
||||
.map { response in Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.sink { _ in
|
||||
} receiveValue: { [weak self] result in
|
||||
self?.searchResult.value = result.value
|
||||
.switchToLatest()
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard self.isSearching.value else { return }
|
||||
self.searchResult.value = response.value
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
isSearching
|
||||
.sink { [weak self] isSearching in
|
||||
if !isSearching {
|
||||
@ -147,48 +159,71 @@ final class SearchViewModel: NSObject {
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewDidAppeared
|
||||
.compactMap { _ in self.requestRecommendHashTags() }
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if !self.recommendHashTags.isEmpty {
|
||||
guard let dataSource = self.hashtagDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendHashTags, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
|
||||
return activeMastodonAuthenticationBox
|
||||
}
|
||||
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
|
||||
.flatMap { box in
|
||||
context.apiService.recommendTrends(domain: box.domain, query: nil)
|
||||
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard let dataSource = self.hashtagDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(response.value, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewDidAppeared
|
||||
.compactMap { _ in self.requestRecommendAccountsV2() }
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if !self.recommendAccounts.isEmpty {
|
||||
self.applyDataSource()
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
recommendAccountsFallback
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.requestRecommendAccounts()
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if !self.recommendAccounts.isEmpty {
|
||||
self.applyDataSource()
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
|
||||
return activeMastodonAuthenticationBox
|
||||
}
|
||||
.throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
|
||||
.flatMap { box -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
|
||||
context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
|
||||
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } }
|
||||
.catch { error -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
|
||||
if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound {
|
||||
return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
|
||||
.map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } }
|
||||
.catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) }
|
||||
.eraseToAnyPublisher()
|
||||
} else {
|
||||
return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error })
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let userIDs):
|
||||
self.receiveAccounts(ids: userIDs)
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
searchResult
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -217,96 +252,7 @@ final class SearchViewModel: NSObject {
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func requestRecommendHashTags() -> Future<Void, Error> {
|
||||
Future { promise in
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||
return
|
||||
}
|
||||
self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function)
|
||||
promise(.success(()))
|
||||
}
|
||||
} receiveValue: { [weak self] tags in
|
||||
guard let self = self else { return }
|
||||
self.recommendHashTags = tags.value
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
func requestRecommendAccountsV2() -> Future<Void, Error> {
|
||||
Future { promise in
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||
return
|
||||
}
|
||||
self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
if let apiError = error as? Mastodon.API.Error {
|
||||
if apiError.httpResponseStatus == .notFound {
|
||||
self?.recommendAccountsFallback.send()
|
||||
}
|
||||
}
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
|
||||
promise(.success(()))
|
||||
}
|
||||
} receiveValue: { [weak self] accounts in
|
||||
guard let self = self else { return }
|
||||
let ids = accounts.value.compactMap({$0.account.id})
|
||||
self.receiveAccounts(ids: ids)
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
func requestRecommendAccounts() -> Future<Void, Error> {
|
||||
Future { promise in
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||
return
|
||||
}
|
||||
self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
promise(.failure(error))
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
|
||||
promise(.success(()))
|
||||
}
|
||||
} receiveValue: { [weak self] accounts in
|
||||
guard let self = self else { return }
|
||||
let ids = accounts.value.compactMap({$0.id})
|
||||
self.receiveAccounts(ids: ids)
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
func applyDataSource() {
|
||||
DispatchQueue.main.async {
|
||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveAccounts(ids: [String]) {
|
||||
func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
@ -323,12 +269,23 @@ final class SearchViewModel: NSObject {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
if let users = mastodonUsers {
|
||||
let sortedUsers = users.sorted { (user1, user2) -> Bool in
|
||||
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
|
||||
guard let users = mastodonUsers else { return }
|
||||
let objectIDs: [NSManagedObjectID] = users
|
||||
.compactMap { object in
|
||||
ids.firstIndex(of: object.id).map { index in (index, object) }
|
||||
}
|
||||
recommendAccounts = sortedUsers.map(\.objectID)
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { $0.1.objectID }
|
||||
|
||||
// append at front
|
||||
let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) }
|
||||
self.recommendAccounts = newObjectIDs + self.recommendAccounts
|
||||
|
||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
|
||||
|
@ -217,6 +217,21 @@ final class StatusView: UIView {
|
||||
metaText.textView.textContainer.lineFragmentPadding = 0
|
||||
metaText.textView.textContainerInset = .zero
|
||||
metaText.textView.layer.masksToBounds = false
|
||||
let paragraphStyle: NSMutableParagraphStyle = {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = 5
|
||||
return style
|
||||
}()
|
||||
metaText.textAttributes = [
|
||||
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
|
||||
.foregroundColor: Asset.Colors.Label.primary.color,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
metaText.linkAttributes = [
|
||||
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
|
||||
.foregroundColor: Asset.Colors.brandBlue.color,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
return metaText
|
||||
}()
|
||||
|
||||
@ -338,7 +353,9 @@ extension StatusView {
|
||||
nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
|
||||
dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
visibilityImageView.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
|
||||
visibilityImageView.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||
visibilityImageView.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||
visibilityImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
|
||||
// subtitle container: [username]
|
||||
let subtitleContainerStackView = UIStackView()
|
||||
|
@ -37,7 +37,8 @@ final class VideoPlayerViewModel {
|
||||
|
||||
private var timeControlStatusObservation: NSKeyValueObservation?
|
||||
let timeControlStatus = CurrentValueSubject<AVPlayer.TimeControlStatus, Never>(.paused)
|
||||
|
||||
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
|
||||
|
||||
init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) {
|
||||
self.previewImageURL = previewImageURL
|
||||
self.videoURL = videoURL
|
||||
@ -58,18 +59,42 @@ final class VideoPlayerViewModel {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription)
|
||||
self.timeControlStatus.value = player.timeControlStatus
|
||||
}
|
||||
|
||||
// update audio session category for user interactive event stream
|
||||
|
||||
player.publisher(for: \.status, options: [.initial, .new])
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
switch status {
|
||||
case .failed:
|
||||
self.playbackState.value = .failed
|
||||
case .readyToPlay:
|
||||
self.playbackState.value = .readyToPlay
|
||||
case .unknown:
|
||||
self.playbackState.value = .unknown
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
timeControlStatus
|
||||
.sink { [weak self] timeControlStatus in
|
||||
guard let _ = self else { return }
|
||||
guard timeControlStatus == .playing else { return }
|
||||
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
|
||||
switch videoKind {
|
||||
case .gif:
|
||||
break
|
||||
case .video:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
|
||||
guard let self = self else { return }
|
||||
|
||||
// emit playing event
|
||||
if timeControlStatus == .playing {
|
||||
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
|
||||
}
|
||||
|
||||
switch timeControlStatus {
|
||||
case .paused:
|
||||
self.playbackState.value = .paused
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
self.playbackState.value = .buffering
|
||||
case .playing:
|
||||
self.playbackState.value = .playing
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
self.playbackState.value = .unknown
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
@ -81,6 +106,27 @@ final class VideoPlayerViewModel {
|
||||
isPlay ? self.play() : self.pause()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let sessionName = videoKind == .gif ? "GIF" : "Video"
|
||||
playbackState
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] status in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s status: %s", ((#file as NSString).lastPathComponent), #line, #function, sessionName, status.description)
|
||||
guard let self = self else { return }
|
||||
// only update audio session for video
|
||||
guard self.videoKind == .video else { return }
|
||||
switch status {
|
||||
case .unknown, .buffering, .readyToPlay:
|
||||
break
|
||||
case .playing:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
case .paused, .stopped, .failed:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -107,7 +153,8 @@ extension VideoPlayerViewModel {
|
||||
case .gif:
|
||||
break
|
||||
case .video:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
|
||||
break
|
||||
// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
@ -53,7 +53,7 @@ class ThreadViewModel {
|
||||
self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) })
|
||||
self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil)
|
||||
self.navigationBarTitle = CurrentValueSubject(
|
||||
optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.emojiDict) }
|
||||
optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.author.emojiDict) }
|
||||
)
|
||||
|
||||
// bind fetcher domain
|
||||
@ -239,7 +239,7 @@ extension ThreadViewModel {
|
||||
nextID = object.inReplyToID
|
||||
}
|
||||
}
|
||||
return nodes.reversed()
|
||||
return nodes
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,6 @@ final class AudioPlaybackService: NSObject {
|
||||
var statusObserver: Any?
|
||||
var attachment: Attachment?
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
|
||||
|
||||
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
|
||||
@ -31,6 +30,23 @@ final class AudioPlaybackService: NSObject {
|
||||
override init() {
|
||||
super.init()
|
||||
addObserver()
|
||||
|
||||
playbackState
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { status in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: audio status: %s", ((#file as NSString).lastPathComponent), #line, #function, status.description)
|
||||
switch status {
|
||||
case .unknown, .buffering, .readyToPlay:
|
||||
break
|
||||
case .playing:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
case .paused, .stopped, .failed:
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,12 +55,6 @@ extension AudioPlaybackService {
|
||||
guard let url = URL(string: audioAttachment.url) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try session.setCategory(.playback)
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
|
||||
notifyWillPlayAudioNotification()
|
||||
if audioAttachment == attachment {
|
||||
@ -64,27 +74,6 @@ extension AudioPlaybackService {
|
||||
}
|
||||
|
||||
func addObserver() {
|
||||
UIDevice.current.isProximityMonitoringEnabled = true
|
||||
NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if UIDevice.current.proximityState == true {
|
||||
do {
|
||||
try self.session.setCategory(.playAndRecord)
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try self.session.setCategory(.playback)
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
@ -96,7 +85,7 @@ extension AudioPlaybackService {
|
||||
guard let self = self else { return }
|
||||
self.currentTimeSubject.value = time.seconds
|
||||
})
|
||||
player.publisher(for: \.status, options: .new)
|
||||
player.publisher(for: \.status, options: [.initial, .new])
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
switch status {
|
||||
|
@ -23,3 +23,21 @@ public enum PlaybackState : Int {
|
||||
|
||||
case failed = 6
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
extension PlaybackState: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .unknown: return "unknown"
|
||||
case .buffering: return "buffering"
|
||||
case .readyToPlay: return "readyToPlay"
|
||||
case .playing: return "playing"
|
||||
case .paused: return "paused"
|
||||
case .stopped: return "stopped"
|
||||
case .failed: return "failed"
|
||||
default:
|
||||
assertionFailure()
|
||||
return "<nil>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ extension VideoPlaybackService {
|
||||
} else {
|
||||
if latestPlayingVideoPlayerViewModel === playerViewModel {
|
||||
latestPlayingVideoPlayerViewModel = nil
|
||||
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
|
||||
// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -111,7 +111,7 @@ extension VideoPlaybackService {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
|
||||
|
||||
// note: do not retain view controller
|
||||
// pause all player when view disppear exclude full screen player and other transitioning scene
|
||||
// pause all player when view disappear exclude full screen player and other transitioning scene
|
||||
for viewModel in viewPlayerViewModelDict.values {
|
||||
guard !viewModel.isTransitioning else {
|
||||
viewModel.isTransitioning = false
|
||||
|
@ -9,6 +9,7 @@ import os.log
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import AppShared
|
||||
import AVFoundation
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
@ -55,7 +56,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
|
@ -83,6 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
AppContext.shared.audioPlaybackService.pauseIfNeed()
|
||||
}
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user