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
}
@ -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,9 +6096,48 @@ 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)
let contextItems = baseRatePromise.get()
|> mapToSignal { [weak self, weak component] baseRate -> Signal<ContextController.Items , NoError> in
guard let self, let component else {
return .complete()
}
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 additionalCount = component.slice.item.storyItem.privacy?.additionallyIncludePeers.count ?? 0
let privacyText: String
switch component.slice.item.storyItem.privacy?.base {
@ -6181,9 +6288,10 @@ public final class StoryItemSetContainerComponent: Component {
let (tip, tipSignal) = self.getLinkedStickerPacks()
let contextItems = ContextController.Items(content: .list(items), tip: tip, tipSignal: tipSignal)
return .single(ContextController.Items(id: 0, 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,9 +6314,46 @@ 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)
let contextItems = baseRatePromise.get()
|> mapToSignal { [weak self, weak component] baseRate -> Signal<ContextController.Items , NoError> in
guard let self, let component else {
return .complete()
}
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)
}
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)
@ -6427,10 +6572,10 @@ public final class StoryItemSetContainerComponent: Component {
}
let (tip, tipSignal) = self.getLinkedStickerPacks()
return .single(ContextController.Items(id: 0, content: .list(items), tip: tip, tipSignal: tipSignal))
})
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()
})
}