Story video playback speed

This commit is contained in:
Ilya Laktyushin 2024-01-28 23:46:54 +04:00
parent a25e41ff73
commit 83e0f2c4a3
4 changed files with 558 additions and 317 deletions

View File

@ -94,7 +94,8 @@ swift_library(
"//submodules/Components/BalancedTextComponent",
"//submodules/AnimatedCountLabelNode",
"//submodules/StickerResources",
"//submodules/TelegramUI/Components/Stories/ForwardInfoPanelComponent"
"//submodules/TelegramUI/Components/Stories/ForwardInfoPanelComponent",
"//submodules/TelegramUI/Components/SliderContextItem",
],
visibility = [
"//visibility:public",

View File

@ -31,6 +31,7 @@ public final class StoryContentItem: Equatable {
public final class SharedState {
public var replyDrafts: [StoryId: NSAttributedString] = [:]
public var baseRate: Double = 1.0
public init() {
}
@ -55,6 +56,9 @@ public final class StoryContentItem: Equatable {
open func enterAmbientMode(ambient: Bool) {
}
open func setBaseRate(_ baseRate: Double) {
}
open var videoPlaybackPosition: Double? {
return nil
}

View File

@ -33,11 +33,12 @@ final class StoryItemContentComponent: Component {
let availableReactions: StoryAvailableReactions?
let entityFiles: [MediaId: TelegramMediaFile]
let audioMode: StoryContentItem.AudioMode
let baseRate: Double
let isVideoBuffering: Bool
let isCurrent: Bool
let activateReaction: (UIView, MessageReaction.Reaction) -> Void
init(context: AccountContext, strings: PresentationStrings, peer: EnginePeer, item: EngineStoryItem, availableReactions: StoryAvailableReactions?, entityFiles: [MediaId: TelegramMediaFile], audioMode: StoryContentItem.AudioMode, isVideoBuffering: Bool, isCurrent: Bool, activateReaction: @escaping (UIView, MessageReaction.Reaction) -> Void) {
init(context: AccountContext, strings: PresentationStrings, peer: EnginePeer, item: EngineStoryItem, availableReactions: StoryAvailableReactions?, entityFiles: [MediaId: TelegramMediaFile], audioMode: StoryContentItem.AudioMode, baseRate: Double, isVideoBuffering: Bool, isCurrent: Bool, activateReaction: @escaping (UIView, MessageReaction.Reaction) -> Void) {
self.context = context
self.strings = strings
self.peer = peer
@ -45,6 +46,7 @@ final class StoryItemContentComponent: Component {
self.entityFiles = entityFiles
self.availableReactions = availableReactions
self.audioMode = audioMode
self.baseRate = baseRate
self.isVideoBuffering = isVideoBuffering
self.isCurrent = isCurrent
self.activateReaction = activateReaction
@ -69,6 +71,9 @@ final class StoryItemContentComponent: Component {
if lhs.entityFiles.keys != rhs.entityFiles.keys {
return false
}
if lhs.baseRate != rhs.baseRate {
return false
}
if lhs.isVideoBuffering != rhs.isVideoBuffering {
return false
}
@ -112,7 +117,7 @@ final class StoryItemContentComponent: Component {
override var videoPlaybackPosition: Double? {
return self.videoPlaybackStatus?.timestamp
}
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private var fetchPriorityResourceId: String?
@ -221,6 +226,7 @@ final class StoryItemContentComponent: Component {
priority: .gallery
)
videoNode.isHidden = true
videoNode.setBaseRate(component.baseRate)
self.videoNode = videoNode
self.insertSubview(videoNode.view, aboveSubview: self.imageView)
@ -325,6 +331,12 @@ final class StoryItemContentComponent: Component {
}
}
override func setBaseRate(_ baseRate: Double) {
if let videoNode = self.videoNode {
videoNode.setBaseRate(baseRate)
}
}
private func updateProgressMode(update: Bool) {
if let videoNode = self.videoNode {
let canPlay = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy

View File

@ -42,6 +42,7 @@ import TranslateUI
import TelegramUIPreferences
import StoryFooterPanelComponent
import TelegramNotices
import SliderContextItem
public final class StoryAvailableReactions: Equatable {
let reactionItems: [ReactionItem]
@ -6021,6 +6022,73 @@ public final class StoryItemSetContainerComponent: Component {
return (tip, tipSignal)
}
private func contextMenuSpeedItems(value: ValuePromise<Double>) -> Signal<[ContextMenuItem], NoError> {
guard let component = self.component else {
return .single([])
}
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
let baseRate = component.storyItemSharedState.baseRate
let valuePromise = ValuePromise<Double?>(nil)
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
}, iconPosition: .left, action: { c, _ in
c.popItems()
})))
items.append(.custom(SliderContextItem(minValue: 0.2, maxValue: 2.5, value: baseRate, valueChanged: { [weak self] newValue, done in
guard let self, let component = self.component else {
return
}
func normalizeValue(_ value: CGFloat) -> CGFloat {
return round(value * 10.0) / 10.0
}
let rate = normalizeValue(newValue)
if let visibleItem = self.visibleItems[component.slice.item.storyItem.id], let view = visibleItem.view.view as? StoryItemContentComponent.View {
view.setBaseRate(rate)
}
component.storyItemSharedState.baseRate = rate
valuePromise.set(rate)
if done {
value.set(rate)
}
}), true))
items.append(.separator)
for (text, _, rate) in speedList(strings: presentationData.strings) {
let isSelected = abs(baseRate - rate) < 0.01
items.append(.action(ContextMenuActionItem(text: text, icon: { _ in return nil }, iconSource: ContextMenuActionItemIconSource(size: CGSize(width: 24.0, height: 24.0), signal: valuePromise.get()
|> map { value in
if isSelected && value == nil {
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white)
} else {
return nil
}
}), action: { [weak self] _, f in
f(.default)
guard let self, let component = self.component else {
return
}
if let visibleItem = self.visibleItems[component.slice.item.storyItem.id], let view = visibleItem.view.view as? StoryItemContentComponent.View {
view.setBaseRate(rate)
}
component.storyItemSharedState.baseRate = rate
})))
}
return .single(items)
}
private func performMyMoreAction(sourceView: UIView, gesture: ContextGesture?) {
guard let component = self.component, let controller = component.controller() else {
return
@ -6028,119 +6096,95 @@ public final class StoryItemSetContainerComponent: Component {
self.dismissAllTooltips()
let baseRatePromise = ValuePromise<Double>(component.storyItemSharedState.baseRate)
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
var items: [ContextMenuItem] = []
let additionalCount = component.slice.item.storyItem.privacy?.additionallyIncludePeers.count ?? 0
let privacyText: String
switch component.slice.item.storyItem.privacy?.base {
case .closeFriends:
privacyText = component.strings.Story_ContextPrivacy_LabelCloseFriends
case .contacts:
if additionalCount != 0 {
privacyText = component.strings.Story_ContextPrivacy_LabelContactsExcept("\(additionalCount)").string
} else {
privacyText = component.strings.Story_ContextPrivacy_LabelContacts
}
case .nobody:
if additionalCount != 0 {
privacyText = component.strings.Story_ContextPrivacy_LabelOnlySelected(Int32(additionalCount))
} else {
privacyText = component.strings.Story_ContextPrivacy_LabelOnlyMe
}
default:
privacyText = component.strings.Story_ContextPrivacy_LabelEveryone
}
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Privacy, textLayout: .secondLineWithValue(privacyText), icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Channels"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
let contextItems = baseRatePromise.get()
|> mapToSignal { [weak self, weak component] baseRate -> Signal<ContextController.Items , NoError> in
guard let self, let component else {
return .complete()
}
guard let self else {
return
}
self.openItemPrivacySettings()
})))
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Edit, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
var items: [ContextMenuItem] = []
guard let self else {
return
if case .file = component.slice.item.storyItem.media {
var speedValue: String = presentationData.strings.PlaybackSpeed_Normal
var speedIconText: String = "1x"
var didSetSpeedValue = false
for (text, iconText, speed) in speedList(strings: presentationData.strings) {
if abs(speed - baseRate) < 0.01 {
speedValue = text
speedIconText = iconText
didSetSpeedValue = true
break
}
}
if !didSetSpeedValue && baseRate != 1.0 {
speedValue = String(format: "%.1fx", baseRate)
speedIconText = speedValue
}
items.append(.action(ContextMenuActionItem(text: presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in
return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
guard let self else {
c.dismiss(completion: nil)
return
}
c.pushItems(items: self.contextMenuSpeedItems(value: baseRatePromise) |> map { ContextController.Items(content: .list($0)) })
})))
items.append(.separator)
}
self.openStoryEditing()
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: component.slice.item.storyItem.isPinned ? component.strings.Story_Context_RemoveFromProfile : component.strings.Story_Context_SaveToProfile, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: component.slice.item.storyItem.isPinned ? "Stories/Context Menu/Unpin" : "Stories/Context Menu/Pin"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
let additionalCount = component.slice.item.storyItem.privacy?.additionallyIncludePeers.count ?? 0
let privacyText: String
switch component.slice.item.storyItem.privacy?.base {
case .closeFriends:
privacyText = component.strings.Story_ContextPrivacy_LabelCloseFriends
case .contacts:
if additionalCount != 0 {
privacyText = component.strings.Story_ContextPrivacy_LabelContactsExcept("\(additionalCount)").string
} else {
privacyText = component.strings.Story_ContextPrivacy_LabelContacts
}
case .nobody:
if additionalCount != 0 {
privacyText = component.strings.Story_ContextPrivacy_LabelOnlySelected(Int32(additionalCount))
} else {
privacyText = component.strings.Story_ContextPrivacy_LabelOnlyMe
}
default:
privacyText = component.strings.Story_ContextPrivacy_LabelEveryone
}
let _ = component.context.engine.messages.updateStoriesArePinned(peerId: component.slice.peer.id, ids: [component.slice.item.storyItem.id: component.slice.item.storyItem], isPinned: !component.slice.item.storyItem.isPinned).startStandalone()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
if component.slice.item.storyItem.isPinned {
self.component?.presentController(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: component.strings.Story_ToastRemovedFromProfileText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
} else {
self.component?.presentController(UndoOverlayController(
presentationData: presentationData,
content: .info(title: component.strings.Story_ToastSavedToProfileTitle, text: component.strings.Story_ToastSavedToProfileText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})))
let saveText: String = component.strings.Story_Context_SaveToGallery
items.append(.action(ContextMenuActionItem(text: saveText, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.requestSave()
})))
if case let .user(accountUser) = component.slice.peer {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_ContextStealthMode, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: accountUser.isPremium ? "Chat/Context Menu/Eye" : "Chat/Context Menu/EyeLocked"), color: theme.contextMenu.primaryColor)
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Privacy, textLayout: .secondLineWithValue(privacyText), icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Channels"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
if accountUser.isPremium {
self.sendMessageContext.requestStealthMode(view: self)
} else {
self.presentStealthModeUpgradeScreen()
}
self.openItemPrivacySettings()
})))
}
if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) && (component.slice.item.storyItem.expirationTimestamp > Int32(Date().timeIntervalSince1970) || component.slice.item.storyItem.isPinned) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_CopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Edit, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.openStoryEditing()
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: component.slice.item.storyItem.isPinned ? component.strings.Story_Context_RemoveFromProfile : component.strings.Story_Context_SaveToProfile, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: component.slice.item.storyItem.isPinned ? "Stories/Context Menu/Unpin" : "Stories/Context Menu/Pin"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
@ -6148,42 +6192,106 @@ public final class StoryItemSetContainerComponent: Component {
return
}
let _ = (component.context.engine.messages.exportStoryLink(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id)
|> deliverOnMainQueue).startStandalone(next: { [weak self] link in
guard let self, let component = self.component else {
return
}
if let link {
UIPasteboard.general.string = link
component.presentController(UndoOverlayController(
presentationData: presentationData,
content: .linkCopied(text: component.strings.Story_ToastLinkCopied),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})
let _ = component.context.engine.messages.updateStoriesArePinned(peerId: component.slice.peer.id, ids: [component.slice.item.storyItem.id: component.slice.item.storyItem], isPinned: !component.slice.item.storyItem.isPinned).startStandalone()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
if component.slice.item.storyItem.isPinned {
self.component?.presentController(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: component.strings.Story_ToastRemovedFromProfileText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
} else {
self.component?.presentController(UndoOverlayController(
presentationData: presentationData,
content: .info(title: component.strings.Story_ToastSavedToProfileTitle, text: component.strings.Story_ToastSavedToProfileText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})))
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Share, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
let saveText: String = component.strings.Story_Context_SaveToGallery
items.append(.action(ContextMenuActionItem(text: saveText, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.sendMessageContext.performShareAction(view: self)
self.requestSave()
})))
}
if case let .user(accountUser) = component.slice.peer {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_ContextStealthMode, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: accountUser.isPremium ? "Chat/Context Menu/Eye" : "Chat/Context Menu/EyeLocked"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
if accountUser.isPremium {
self.sendMessageContext.requestStealthMode(view: self)
} else {
self.presentStealthModeUpgradeScreen()
}
})))
}
if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) && (component.slice.item.storyItem.expirationTimestamp > Int32(Date().timeIntervalSince1970) || component.slice.item.storyItem.isPinned) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_CopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
let _ = (component.context.engine.messages.exportStoryLink(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id)
|> deliverOnMainQueue).startStandalone(next: { [weak self] link in
guard let self, let component = self.component else {
return
}
if let link {
UIPasteboard.general.string = link
component.presentController(UndoOverlayController(
presentationData: presentationData,
content: .linkCopied(text: component.strings.Story_ToastLinkCopied),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})
})))
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Share, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.sendMessageContext.performShareAction(view: self)
})))
}
let (tip, tipSignal) = self.getLinkedStickerPacks()
return .single(ContextController.Items(id: 0, content: .list(items), tip: tip, tipSignal: tipSignal))
})
let (tip, tipSignal) = self.getLinkedStickerPacks()
let contextItems = ContextController.Items(content: .list(items), tip: tip, tipSignal: tipSignal)
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView, position: .bottom)), items: .single(contextItems), gesture: gesture)
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView, position: .bottom)), items: contextItems, gesture: gesture)
contextController.dismissed = { [weak self] in
guard let self else {
return
@ -6206,184 +6314,179 @@ public final class StoryItemSetContainerComponent: Component {
self.dismissAllTooltips()
let baseRatePromise = ValuePromise<Double>(component.storyItemSharedState.baseRate)
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
var items: [ContextMenuItem] = []
if (component.slice.item.storyItem.isMy && channel.hasPermission(.postStories)) || channel.hasPermission(.editStories) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Edit, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.openStoryEditing()
})))
}
if !items.isEmpty {
items.append(.separator)
}
if channel.hasPermission(.editStories) {
items.append(.action(ContextMenuActionItem(text: component.slice.item.storyItem.isPinned ? component.strings.Story_Context_RemoveFromChannel : component.strings.Story_Context_SaveToChannel, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: component.slice.item.storyItem.isPinned ? "Stories/Context Menu/Unpin" : "Stories/Context Menu/Pin"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
let _ = component.context.engine.messages.updateStoriesArePinned(peerId: component.slice.peer.id, ids: [component.slice.item.storyItem.id: component.slice.item.storyItem], isPinned: !component.slice.item.storyItem.isPinned).startStandalone()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
if component.slice.item.storyItem.isPinned {
self.scheduledStoryUnpinnedUndoOverlay = UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: presentationData.strings.Story_ToastRemovedFromChannelText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
)
} else {
self.component?.presentController(UndoOverlayController(
presentationData: presentationData,
content: .info(title: presentationData.strings.Story_ToastSavedToChannelTitle, text: presentationData.strings.Story_ToastSavedToChannelText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})))
}
if component.slice.additionalPeerData.canViewStats {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_ViewStats, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Statistics"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkColorPresentationTheme)
let statsController = component.context.sharedContext.makeStoryStatsController(
context: component.context,
updatedPresentationData: (presentationData, .single(presentationData)),
peerId: component.slice.peer.id,
storyId: component.slice.item.storyItem.id,
storyItem: component.slice.item.storyItem,
fromStory: true
)
component.controller()?.push(statsController)
})))
}
let saveText: String = component.strings.Story_Context_SaveToGallery
items.append(.action(ContextMenuActionItem(text: saveText, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
let contextItems = baseRatePromise.get()
|> mapToSignal { [weak self, weak component] baseRate -> Signal<ContextController.Items , NoError> in
guard let self, let component else {
return .complete()
}
self.requestSave()
})))
if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) && (component.slice.item.storyItem.expirationTimestamp > Int32(Date().timeIntervalSince1970) || component.slice.item.storyItem.isPinned) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_CopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
var items: [ContextMenuItem] = []
if case .file = component.slice.item.storyItem.media {
var speedValue: String = presentationData.strings.PlaybackSpeed_Normal
var speedIconText: String = "1x"
var didSetSpeedValue = false
for (text, iconText, speed) in speedList(strings: presentationData.strings) {
if abs(speed - baseRate) < 0.01 {
speedValue = text
speedIconText = iconText
didSetSpeedValue = true
break
}
}
if !didSetSpeedValue && baseRate != 1.0 {
speedValue = String(format: "%.1fx", baseRate)
speedIconText = speedValue
}
let _ = (component.context.engine.messages.exportStoryLink(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id)
|> deliverOnMainQueue).startStandalone(next: { [weak self] link in
items.append(.action(ContextMenuActionItem(text: presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in
return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
guard let self else {
c.dismiss(completion: nil)
return
}
c.pushItems(items: self.contextMenuSpeedItems(value: baseRatePromise) |> map { ContextController.Items(content: .list($0)) })
})))
items.append(.separator)
}
if (component.slice.item.storyItem.isMy && channel.hasPermission(.postStories)) || channel.hasPermission(.editStories) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Edit, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.openStoryEditing()
})))
}
if !items.isEmpty {
items.append(.separator)
}
if channel.hasPermission(.editStories) {
items.append(.action(ContextMenuActionItem(text: component.slice.item.storyItem.isPinned ? component.strings.Story_Context_RemoveFromChannel : component.strings.Story_Context_SaveToChannel, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: component.slice.item.storyItem.isPinned ? "Stories/Context Menu/Unpin" : "Stories/Context Menu/Pin"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
if let link {
UIPasteboard.general.string = link
component.presentController(UndoOverlayController(
let _ = component.context.engine.messages.updateStoriesArePinned(peerId: component.slice.peer.id, ids: [component.slice.item.storyItem.id: component.slice.item.storyItem], isPinned: !component.slice.item.storyItem.isPinned).startStandalone()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
if component.slice.item.storyItem.isPinned {
self.scheduledStoryUnpinnedUndoOverlay = UndoOverlayController(
presentationData: presentationData,
content: .linkCopied(text: component.strings.Story_ToastLinkCopied),
content: .info(title: nil, text: presentationData.strings.Story_ToastRemovedFromChannelText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
)
} else {
self.component?.presentController(UndoOverlayController(
presentationData: presentationData,
content: .info(title: presentationData.strings.Story_ToastSavedToChannelTitle, text: presentationData.strings.Story_ToastSavedToChannelText, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})
})))
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Share, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
})))
}
if component.slice.additionalPeerData.canViewStats {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_ViewStats, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Statistics"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkColorPresentationTheme)
let statsController = component.context.sharedContext.makeStoryStatsController(
context: component.context,
updatedPresentationData: (presentationData, .single(presentationData)),
peerId: component.slice.peer.id,
storyId: component.slice.item.storyItem.id,
storyItem: component.slice.item.storyItem,
fromStory: true
)
component.controller()?.push(statsController)
})))
}
let saveText: String = component.strings.Story_Context_SaveToGallery
items.append(.action(ContextMenuActionItem(text: saveText, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.sendMessageContext.performShareAction(view: self)
self.requestSave()
})))
}
var isHidden = false
if case let .channel(channel) = component.slice.peer, let storiesHidden = channel.storiesHidden {
isHidden = storiesHidden
}
items.append(.action(ContextMenuActionItem(text: isHidden ? component.strings.StoryFeed_ContextUnarchive : component.strings.StoryFeed_ContextArchive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: isHidden ? "Chat/Context Menu/Unarchive" : "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
let _ = component.context.engine.peers.updatePeerStoriesHidden(id: component.slice.peer.id, isHidden: !isHidden)
let text = !isHidden ? component.strings.StoryFeed_TooltipArchive(component.slice.peer.compactDisplayTitle).string : component.strings.StoryFeed_TooltipUnarchive(component.slice.peer.compactDisplayTitle).string
let tooltipScreen = TooltipScreen(
context: component.context,
account: component.context.account,
sharedContext: component.context.sharedContext,
text: .markdown(text: text),
style: .customBlur(UIColor(rgb: 0x1c1c1c), 0.0),
icon: .peer(peer: component.slice.peer, isStory: true),
action: TooltipScreen.Action(
title: component.strings.Undo_Undo,
action: {
component.context.engine.peers.updatePeerStoriesHidden(id: component.slice.peer.id, isHidden: isHidden)
if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) && (component.slice.item.storyItem.expirationTimestamp > Int32(Date().timeIntervalSince1970) || component.slice.item.storyItem.isPinned) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_CopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
),
location: .bottom,
shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) }
)
tooltipScreen.willBecomeDismissed = { [weak self] _ in
guard let self else {
return
}
self.sendMessageContext.tooltipScreen = nil
self.updateIsProgressPaused()
let _ = (component.context.engine.messages.exportStoryLink(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id)
|> deliverOnMainQueue).startStandalone(next: { [weak self] link in
guard let self, let component = self.component else {
return
}
if let link {
UIPasteboard.general.string = link
component.presentController(UndoOverlayController(
presentationData: presentationData,
content: .linkCopied(text: component.strings.Story_ToastLinkCopied),
elevatedLayout: false,
animateInAsReplacement: false,
blurred: true,
action: { _ in return false }
), nil)
}
})
})))
items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Share, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.sendMessageContext.performShareAction(view: self)
})))
}
self.sendMessageContext.tooltipScreen?.dismiss()
self.sendMessageContext.tooltipScreen = tooltipScreen
self.updateIsProgressPaused()
component.controller()?.present(tooltipScreen, in: .current)
})))
if (component.slice.item.storyItem.isMy && channel.hasPermission(.postStories)) || channel.hasPermission(.deleteStories) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_ContextDeleteStory, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
var isHidden = false
if case let .channel(channel) = component.slice.peer, let storiesHidden = channel.storiesHidden {
isHidden = storiesHidden
}
items.append(.action(ContextMenuActionItem(text: isHidden ? component.strings.StoryFeed_ContextUnarchive : component.strings.StoryFeed_ContextArchive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: isHidden ? "Chat/Context Menu/Unarchive" : "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
@ -6391,46 +6494,88 @@ public final class StoryItemSetContainerComponent: Component {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
let actionSheet = ActionSheetController(presentationData: presentationData)
let _ = component.context.engine.peers.updatePeerStoriesHidden(id: component.slice.peer.id, isHidden: !isHidden)
actionSheet.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: component.strings.Story_ContextDeleteStory, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self, let component = self.component else {
return
}
component.delete()
})
]),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
actionSheet.dismissed = { [weak self] _ in
let text = !isHidden ? component.strings.StoryFeed_TooltipArchive(component.slice.peer.compactDisplayTitle).string : component.strings.StoryFeed_TooltipUnarchive(component.slice.peer.compactDisplayTitle).string
let tooltipScreen = TooltipScreen(
context: component.context,
account: component.context.account,
sharedContext: component.context.sharedContext,
text: .markdown(text: text),
style: .customBlur(UIColor(rgb: 0x1c1c1c), 0.0),
icon: .peer(peer: component.slice.peer, isStory: true),
action: TooltipScreen.Action(
title: component.strings.Undo_Undo,
action: {
component.context.engine.peers.updatePeerStoriesHidden(id: component.slice.peer.id, isHidden: isHidden)
}
),
location: .bottom,
shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) }
)
tooltipScreen.willBecomeDismissed = { [weak self] _ in
guard let self else {
return
}
self.sendMessageContext.actionSheet = nil
self.sendMessageContext.tooltipScreen = nil
self.updateIsProgressPaused()
}
self.sendMessageContext.actionSheet = actionSheet
self.sendMessageContext.tooltipScreen?.dismiss()
self.sendMessageContext.tooltipScreen = tooltipScreen
self.updateIsProgressPaused()
component.presentController(actionSheet, nil)
component.controller()?.present(tooltipScreen, in: .current)
})))
}
if (component.slice.item.storyItem.isMy && channel.hasPermission(.postStories)) || channel.hasPermission(.deleteStories) {
items.append(.action(ContextMenuActionItem(text: component.strings.Story_ContextDeleteStory, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
let actionSheet = ActionSheetController(presentationData: presentationData)
actionSheet.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: component.strings.Story_ContextDeleteStory, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self, let component = self.component else {
return
}
component.delete()
})
]),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
actionSheet.dismissed = { [weak self] _ in
guard let self else {
return
}
self.sendMessageContext.actionSheet = nil
self.updateIsProgressPaused()
}
self.sendMessageContext.actionSheet = actionSheet
self.updateIsProgressPaused()
component.presentController(actionSheet, nil)
})))
}
let (tip, tipSignal) = self.getLinkedStickerPacks()
return .single(ContextController.Items(id: 0, content: .list(items), tip: tip, tipSignal: tipSignal))
})
let (tip, tipSignal) = self.getLinkedStickerPacks()
let contextItems = ContextController.Items(content: .list(items), tip: tip, tipSignal: tipSignal)
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView, position: .bottom)), items: .single(contextItems), gesture: gesture)
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView, position: .bottom)), items: contextItems, gesture: gesture)
contextController.dismissed = { [weak self] in
guard let self else {
return
@ -6448,6 +6593,8 @@ public final class StoryItemSetContainerComponent: Component {
return
}
let baseRatePromise = ValuePromise<Double>(component.storyItemSharedState.baseRate)
let translationSettings = component.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings])
|> map { sharedData -> TranslationSettings in
let translationSettings: TranslationSettings
@ -6468,9 +6615,10 @@ public final class StoryItemSetContainerComponent: Component {
TelegramEngine.EngineData.Item.Peer.IsContact(id: component.slice.peer.id),
TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId)
),
translationSettings
translationSettings,
baseRatePromise.get()
)
|> take(1)).startStandalone(next: { [weak self] result, translationSettings in
|> take(1)).startStandalone(next: { [weak self] result, translationSettings, baseRate in
guard let self, let component = self.component, let controller = component.controller() else {
return
}
@ -6486,6 +6634,36 @@ public final class StoryItemSetContainerComponent: Component {
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
var items: [ContextMenuItem] = []
if case .file = component.slice.item.storyItem.media {
var speedValue: String = presentationData.strings.PlaybackSpeed_Normal
var speedIconText: String = "1x"
var didSetSpeedValue = false
for (text, iconText, speed) in speedList(strings: presentationData.strings) {
if abs(speed - baseRate) < 0.01 {
speedValue = text
speedIconText = iconText
didSetSpeedValue = true
break
}
}
if !didSetSpeedValue && baseRate != 1.0 {
speedValue = String(format: "%.1fx", baseRate)
speedIconText = speedValue
}
items.append(.action(ContextMenuActionItem(text: presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in
return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
guard let self else {
c.dismiss(completion: nil)
return
}
c.pushItems(items: self.contextMenuSpeedItems(value: baseRatePromise) |> map { ContextController.Items(content: .list($0)) })
})))
items.append(.separator)
}
let isMuted = resolvedAreStoriesMuted(globalSettings: globalSettings._asGlobalNotificationSettings(), peer: component.slice.peer._asPeer(), peerSettings: settings._asNotificationSettings(), topSearchPeers: topSearchPeers)
if !component.slice.peer.isService && isContact {
@ -6939,3 +7117,49 @@ private func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to tar
return keyframes
}
private func speedList(strings: PresentationStrings) -> [(String, String, Double)] {
return [
("0.5x", "0.5x", 0.5),
(strings.PlaybackSpeed_Normal, "1x", 1.0),
("1.5x", "1.5x", 1.5),
("2x", "2x", 2.0)
]
}
private func optionsRateImage(rate: String, isLarge: Bool, color: UIColor = .white) -> UIImage? {
return generateImage(isLarge ? CGSize(width: 30.0, height: 30.0) : CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in
UIGraphicsPushContext(context)
context.clear(CGRect(origin: CGPoint(), size: size))
if let image = generateTintedImage(image: UIImage(bundleImageName: isLarge ? "Chat/Context Menu/Playspeed30" : "Chat/Context Menu/Playspeed24"), color: .white) {
image.draw(at: CGPoint(x: 0.0, y: 0.0))
}
let string = NSMutableAttributedString(string: rate, font: Font.with(size: isLarge ? 11.0 : 10.0, design: .round, weight: .semibold), textColor: color)
var offset = CGPoint(x: 1.0, y: 0.0)
if rate.count >= 3 {
if rate == "0.5x" {
string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
offset.x += -0.5
} else {
string.addAttribute(.kern, value: -0.5 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
offset.x += -0.3
}
} else {
offset.x += -0.3
}
if !isLarge {
offset.x *= 0.5
offset.y *= 0.5
}
let boundingRect = string.boundingRect(with: size, options: [], context: nil)
string.draw(at: CGPoint(x: offset.x + floor((size.width - boundingRect.width) / 2.0), y: offset.y + floor((size.height - boundingRect.height) / 2.0)))
UIGraphicsPopContext()
})
}