Various improvements

This commit is contained in:
Isaac 2024-11-05 15:33:17 +01:00
parent 095b068f63
commit de6836fa5d
15 changed files with 468 additions and 130 deletions

View File

@ -106,6 +106,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
case experimentalCallMute(Bool)
case liveStreamV2(Bool)
case dynamicStreaming(Bool)
case enableLocalTranslation(Bool)
case preferredVideoCodec(Int, String, String?, Bool)
case disableVideoAspectScaling(Bool)
case enableNetworkFramework(Bool)
@ -130,7 +131,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
return DebugControllerSection.web.rawValue
case .keepChatNavigationStack, .skipReadHistory, .dustEffect, .crashOnSlowQueries, .crashOnMemoryPressure:
return DebugControllerSection.experiments.rawValue
case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2, .dynamicStreaming:
case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2, .dynamicStreaming, .enableLocalTranslation:
return DebugControllerSection.experiments.rawValue
case .logTranslationRecognition, .resetTranslationStates:
return DebugControllerSection.translation.rawValue
@ -251,8 +252,10 @@ private enum DebugControllerEntry: ItemListNodeEntry {
return 53
case .dynamicStreaming:
return 54
case .enableLocalTranslation:
return 55
case let .preferredVideoCodec(index, _, _, _):
return 55 + index
return 56 + index
case .disableVideoAspectScaling:
return 100
case .enableNetworkFramework:
@ -1361,6 +1364,16 @@ private enum DebugControllerEntry: ItemListNodeEntry {
})
}).start()
})
case let .enableLocalTranslation(value):
return ItemListSwitchItem(presentationData: presentationData, title: "Local Translation", value: value, sectionId: self.section, style: .blocks, updated: { value in
let _ = arguments.sharedContext.accountManager.transaction ({ transaction in
transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in
var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings
settings.enableLocalTranslation = value
return PreferencesEntry(settings)
})
}).start()
})
case let .preferredVideoCodec(_, title, value, isSelected):
return ItemListCheckboxItem(presentationData: presentationData, title: title, style: .right, checked: isSelected, zeroSeparatorInsets: false, sectionId: self.section, action: {
let _ = arguments.sharedContext.accountManager.transaction ({ transaction in
@ -1519,6 +1532,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present
entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute))
entries.append(.liveStreamV2(experimentalSettings.liveStreamV2))
entries.append(.dynamicStreaming(experimentalSettings.dynamicStreaming))
entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation))
}
/*let codecs: [(String, String?)] = [

View File

@ -246,12 +246,14 @@ public func galleryItemForEntry(
} else {
if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") {
var isHLS = false
if NativeVideoContent.isHLSVideo(file: file) {
isHLS = true
if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double {
if Int(disableHLS) != 0 {
isHLS = false
if #available(iOS 13.0, *) {
if NativeVideoContent.isHLSVideo(file: file) {
isHLS = true
if let data = context.currentAppConfiguration.with({ $0 }).data, let disableHLS = data["video_ignore_alt_documents"] as? Double {
if Int(disableHLS) != 0 {
isHLS = false
}
}
}
}

View File

@ -119,13 +119,15 @@ public final class ChunkMediaPlayerPart {
public let startTime: Double
public let endTime: Double
public let file: TempBoxFile
public let clippedStartTime: Double?
public var id: Id {
return Id(rawValue: self.file.path)
}
public init(startTime: Double, endTime: Double, file: TempBoxFile) {
public init(startTime: Double, clippedStartTime: Double? = nil, endTime: Double, file: TempBoxFile) {
self.startTime = startTime
self.clippedStartTime = clippedStartTime
self.endTime = endTime
self.file = file
}
@ -620,59 +622,55 @@ private final class ChunkMediaPlayerContext {
var validParts: [ChunkMediaPlayerPart] = []
var minStartTime: Double = 0.0
for i in 0 ..< self.partsState.parts.count {
let part = self.partsState.parts[i]
let partStartTime = max(minStartTime, part.startTime)
let partEndTime = max(partStartTime, part.endTime)
if partStartTime >= partEndTime {
continue
}
var partMatches = false
if timestamp >= part.startTime - 0.5 && timestamp < part.endTime + 0.5 {
if timestamp >= partStartTime - 0.5 && timestamp < partEndTime + 0.5 {
partMatches = true
}
if partMatches {
validParts.append(part)
validParts.append(ChunkMediaPlayerPart(
startTime: part.startTime,
clippedStartTime: partStartTime == part.startTime ? nil : partStartTime,
endTime: part.endTime,
file: part.file
))
minStartTime = max(minStartTime, partEndTime)
}
}
if let lastValidPart = validParts.last {
for i in 0 ..< self.partsState.parts.count {
let part = self.partsState.parts[i]
if lastValidPart !== part && part.startTime > lastValidPart.startTime && part.startTime <= lastValidPart.endTime + 0.5 {
validParts.append(part)
let partStartTime = max(minStartTime, part.startTime)
let partEndTime = max(partStartTime, part.endTime)
if partStartTime >= partEndTime {
continue
}
if lastValidPart !== part && partStartTime > (lastValidPart.clippedStartTime ?? lastValidPart.startTime) && partStartTime <= lastValidPart.endTime + 0.5 {
validParts.append(ChunkMediaPlayerPart(
startTime: part.startTime,
clippedStartTime: partStartTime == part.startTime ? nil : partStartTime,
endTime: part.endTime,
file: part.file
))
minStartTime = max(minStartTime, partEndTime)
break
}
}
}
/*for i in 0 ..< self.partsState.parts.count {
let part = self.partsState.parts[i]
var partMatches = false
if timestamp >= part.startTime - 0.001 && timestamp < part.endTime - 0.001 {
partMatches = true
} else if part.startTime < 0.2 && timestamp < part.endTime - 0.001 {
partMatches = true
}
if !partMatches, i != self.partsState.parts.count - 1, part.startTime >= 0.001, timestamp >= part.startTime {
let nextPart = self.partsState.parts[i + 1]
if timestamp < nextPart.endTime - 0.001 {
if part.endTime >= nextPart.startTime - 0.1 {
partMatches = true
}
}
}
if partMatches {
validParts.append(part)
inner: for lookaheadPart in self.partsState.parts {
if lookaheadPart.startTime >= part.endTime - 0.001 && lookaheadPart.startTime - 0.1 < part.endTime {
validParts.append(lookaheadPart)
break inner
}
}
break
}
}*/
if validParts.isEmpty, let initialSeekTimestamp = self.initialSeekTimestamp {
for part in self.partsState.parts {
if initialSeekTimestamp >= part.startTime - 0.2 && initialSeekTimestamp < part.endTime {
@ -701,6 +699,8 @@ private final class ChunkMediaPlayerContext {
self.initialSeekTimestamp = nil
}
//print("validParts: \(validParts.map { "\($0.startTime) ... \($0.endTime)" })")
self.loadedState.partStates.removeAll(where: { partState in
if !validParts.contains(where: { $0.id == partState.part.id }) {
return true
@ -742,7 +742,13 @@ private final class ChunkMediaPlayerContext {
for i in 0 ..< self.loadedState.partStates.count {
let partState = self.loadedState.partStates[i]
if partState.mediaBuffersDisposable == nil {
partState.mediaBuffersDisposable = (partState.frameSource.seek(timestamp: i == 0 ? timestamp : 0.0)
let partSeekOffset: Double
if let clippedStartTime = partState.part.clippedStartTime {
partSeekOffset = clippedStartTime - partState.part.startTime
} else {
partSeekOffset = 0.0
}
partState.mediaBuffersDisposable = (partState.frameSource.seek(timestamp: i == 0 ? timestamp : partSeekOffset)
|> deliverOn(self.queue)).startStrict(next: { [weak self, weak partState] result in
guard let self, let partState else {
return
@ -921,13 +927,14 @@ private final class ChunkMediaPlayerContext {
for partState in self.loadedState.partStates {
if let audioTrackFrameBuffer = partState.mediaBuffers?.audioBuffer {
//print("Poll audio: part \(partState.part.startTime) frames: \(audioTrackFrameBuffer.frames.map(\.pts.seconds))")
let frame = audioTrackFrameBuffer.takeFrame()
switch frame {
case .finished:
continue
default:
/*if case let .frame(frame) = frame {
print("audio: \(frame.position.seconds) \(frame.position.value) next: (\(frame.position.value + frame.duration.value))")
print("audio: \(frame.position.seconds) \(frame.position.value) part \(partState.part.startTime) next: (\(frame.position.value + frame.duration.value))")
}*/
return frame
}

View File

@ -461,6 +461,40 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder {
invite: nil,
activeCall: EngineGroupCallDescription(id: groupCallPanelData.info.id, accessHash: groupCallPanelData.info.accessHash, title: groupCallPanelData.info.title, scheduleTimestamp: groupCallPanelData.info.scheduleTimestamp, subscribedToScheduled: groupCallPanelData.info.subscribedToScheduled, isStream: groupCallPanelData.info.isStream)
)
}, notifyScheduledTapAction: { [weak self] in
guard let self, let groupCallPanelData = self.groupCallPanelData else {
return
}
if groupCallPanelData.info.scheduleTimestamp != nil && !groupCallPanelData.info.subscribedToScheduled {
let _ = self.context.engine.calls.toggleScheduledGroupCallSubscription(peerId: groupCallPanelData.peerId, callId: groupCallPanelData.info.id, accessHash: groupCallPanelData.info.accessHash, subscribe: true).startStandalone()
//TODO:localize
let controller = UndoOverlayController(
presentationData: presentationData,
content: .universal(
animation: "anim_profileunmute",
scale: 0.075,
colors: [
"Middle.Group 1.Fill 1": UIColor.white,
"Top.Group 1.Fill 1": UIColor.white,
"Bottom.Group 1.Fill 1": UIColor.white,
"EXAMPLE.Group 1.Fill 1": UIColor.white,
"Line.Group 1.Stroke 1": UIColor.white
],
title: nil,
text: "You will be notified when the liver stream starts.",
customUndoText: nil,
timeout: nil
),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in
return true
}
)
self.audioRateTooltipController = controller
self.present(controller, in: .current)
}
})
if let accessoryPanelContainer = self.accessoryPanelContainer {
accessoryPanelContainer.addSubnode(groupCallAccessoryPanel)

View File

@ -113,6 +113,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
private var dateTimeFormat: PresentationDateTimeFormat
private let tapAction: () -> Void
private let notifyScheduledTapAction: () -> Void
private let contentNode: ASDisplayNode
@ -163,13 +164,14 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
private var currentData: GroupCallPanelData?
private var validLayout: (CGSize, CGFloat, CGFloat, Bool)?
public init(context: AccountContext, presentationData: PresentationData, tapAction: @escaping () -> Void) {
public init(context: AccountContext, presentationData: PresentationData, tapAction: @escaping () -> Void, notifyScheduledTapAction: @escaping () -> Void) {
self.context = context
self.theme = presentationData.theme
self.strings = presentationData.strings
self.dateTimeFormat = presentationData.dateTimeFormat
self.tapAction = tapAction
self.notifyScheduledTapAction = notifyScheduledTapAction
self.contentNode = ASDisplayNode()
@ -234,7 +236,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
self.joinButton.addSubnode(self.joinButtonBackgroundNode)
self.joinButton.addSubnode(self.joinButtonTitleNode)
self.contentNode.addSubnode(self.joinButton)
self.joinButton.addTarget(self, action: #selector(self.tapped), forControlEvents: [.touchUpInside])
self.joinButton.addTarget(self, action: #selector(self.joinTapped), forControlEvents: [.touchUpInside])
self.micButton.addSubnode(self.micButtonBackgroundNode)
self.micButton.addSubnode(self.micButtonForegroundNode)
@ -267,6 +269,14 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
self.tapAction()
}
@objc private func joinTapped() {
if let info = self.currentData?.info, let _ = info.scheduleTimestamp, !info.subscribedToScheduled {
self.notifyScheduledTapAction()
} else {
self.tapAction()
}
}
@objc private func micTapped() {
guard let call = self.currentData?.groupCall else {
return
@ -645,7 +655,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
var text = self.currentText
var isScheduled = false
var isLate = false
if let scheduleTime = self.currentData?.info.scheduleTimestamp {
if let info = self.currentData?.info, let scheduleTime = info.scheduleTimestamp {
isScheduled = true
if let voiceChatTitle = self.currentData?.info.title {
title = voiceChatTitle
@ -655,15 +665,20 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
text = humanReadableStringForTimestamp(strings: self.strings, dateTimeFormat: self.dateTimeFormat, timestamp: scheduleTime, alwaysShowTime: true, format: HumanReadableStringFormat(dateFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsOnShort($0) }, tomorrowFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsTomorrowShort($0) }, todayFormatString: { self.strings.Conversation_ScheduledVoiceChatStartsTodayShort($0) })).string
}
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let elapsedTime = scheduleTime - currentTime
if elapsedTime >= 86400 {
joinText = scheduledTimeIntervalString(strings: strings, value: elapsedTime)
} else if elapsedTime < 0 {
joinText = "-\(textForTimeout(value: abs(elapsedTime)))"
isLate = true
if info.subscribedToScheduled {
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let elapsedTime = scheduleTime - currentTime
if elapsedTime >= 86400 {
joinText = scheduledTimeIntervalString(strings: strings, value: elapsedTime).uppercased()
} else if elapsedTime < 0 {
joinText = "-\(textForTimeout(value: abs(elapsedTime)))".uppercased()
isLate = true
} else {
joinText = textForTimeout(value: elapsedTime).uppercased()
}
} else {
joinText = textForTimeout(value: elapsedTime)
//TODO:localize
joinText = "Notify Me"
}
if self.updateTimer == nil {
@ -691,7 +706,7 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode {
self.updateJoinButton()
}
self.joinButtonTitleNode.attributedText = NSAttributedString(string: joinText.uppercased(), font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: isScheduled ? .white : self.theme.chat.inputPanel.actionControlForegroundColor)
self.joinButtonTitleNode.attributedText = NSAttributedString(string: joinText, font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: isScheduled ? .white : self.theme.chat.inputPanel.actionControlForegroundColor)
let joinButtonTitleSize = self.joinButtonTitleNode.updateLayout(CGSize(width: 150.0, height: .greatestFiniteMagnitude))
let joinButtonSize = CGSize(width: joinButtonTitleSize.width + 20.0, height: 28.0)

View File

@ -944,7 +944,9 @@ private func boxedDecryptedMessage(transaction: Transaction, message: Message, g
if let attribute = attribute as? ReplyMessageAttribute {
if let message = message.associatedMessages[attribute.messageId] {
replyGlobalId = message.globallyUniqueId
flags |= (1 << 3)
if replyGlobalId != nil {
flags |= (1 << 3)
}
break
}
}

View File

@ -244,40 +244,54 @@ public enum ToggleScheduledGroupCallSubscriptionError {
}
func _internal_toggleScheduledGroupCallSubscription(account: Account, peerId: PeerId, callId: Int64, accessHash: Int64, subscribe: Bool) -> Signal<Void, ToggleScheduledGroupCallSubscriptionError> {
return account.network.request(Api.functions.phone.toggleGroupCallStartSubscription(call: .inputGroupCall(id: callId, accessHash: accessHash), subscribed: subscribe ? .boolTrue : .boolFalse))
|> mapError { error -> ToggleScheduledGroupCallSubscriptionError in
return .generic
}
|> mapToSignal { result -> Signal<Void, ToggleScheduledGroupCallSubscriptionError> in
var parsedCall: GroupCallInfo?
loop: for update in result.allUpdates {
switch update {
case let .updateGroupCall(_, call):
parsedCall = GroupCallInfo(call)
break loop
default:
break
return account.postbox.transaction { transaction -> Void in
transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in
if let cachedData = cachedData as? CachedChannelData, let activeCall = cachedData.activeCall {
return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: activeCall.id, accessHash: activeCall.accessHash, title: activeCall.title, scheduleTimestamp: activeCall.scheduleTimestamp, subscribedToScheduled: true, isStream: activeCall.isStream))
} else if let cachedData = cachedData as? CachedGroupData, let activeCall = cachedData.activeCall {
return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: activeCall.id, accessHash: activeCall.accessHash, title: activeCall.title, scheduleTimestamp: activeCall.scheduleTimestamp, subscribedToScheduled: true, isStream: activeCall.isStream))
} else {
return cachedData
}
})
}
|> castError(ToggleScheduledGroupCallSubscriptionError.self)
|> mapToSignal { _ -> Signal<Void, ToggleScheduledGroupCallSubscriptionError> in
return account.network.request(Api.functions.phone.toggleGroupCallStartSubscription(call: .inputGroupCall(id: callId, accessHash: accessHash), subscribed: subscribe ? .boolTrue : .boolFalse))
|> mapError { error -> ToggleScheduledGroupCallSubscriptionError in
return .generic
}
guard let callInfo = parsedCall else {
return .fail(.generic)
}
return account.postbox.transaction { transaction in
transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in
if let cachedData = cachedData as? CachedChannelData {
return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream))
} else if let cachedData = cachedData as? CachedGroupData {
return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream))
} else {
return cachedData
|> mapToSignal { result -> Signal<Void, ToggleScheduledGroupCallSubscriptionError> in
var parsedCall: GroupCallInfo?
loop: for update in result.allUpdates {
switch update {
case let .updateGroupCall(_, call):
parsedCall = GroupCallInfo(call)
break loop
default:
break
}
})
}
account.stateManager.addUpdates(result)
guard let callInfo = parsedCall else {
return .fail(.generic)
}
return account.postbox.transaction { transaction in
transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData -> CachedPeerData? in
if let cachedData = cachedData as? CachedChannelData {
return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream))
} else if let cachedData = cachedData as? CachedGroupData {
return cachedData.withUpdatedActiveCall(CachedChannelData.ActiveCall(id: callInfo.id, accessHash: callInfo.accessHash, title: callInfo.title, scheduleTimestamp: callInfo.scheduleTimestamp, subscribedToScheduled: callInfo.subscribedToScheduled, isStream: callInfo.isStream))
} else {
return cachedData
}
})
account.stateManager.addUpdates(result)
}
|> castError(ToggleScheduledGroupCallSubscriptionError.self)
}
|> castError(ToggleScheduledGroupCallSubscriptionError.self)
}
}

View File

@ -536,8 +536,8 @@ public extension TelegramEngine {
return _internal_translate_texts(network: self.account.network, texts: texts, toLang: toLang)
}
public func translateMessages(messageIds: [EngineMessage.Id], toLang: String) -> Signal<Never, TranslationError> {
return _internal_translateMessages(account: self.account, messageIds: messageIds, toLang: toLang)
public func translateMessages(messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal<Never, TranslationError> {
return _internal_translateMessages(account: self.account, messageIds: messageIds, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible)
}
public func togglePeerMessagesTranslationHidden(peerId: EnginePeer.Id, hidden: Bool) -> Signal<Never, NoError> {

View File

@ -84,16 +84,22 @@ func _internal_translate_texts(network: Network, texts: [(String, [MessageTextEn
}
}
func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Never, TranslationError> {
func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal<Never, TranslationError> {
var signals: [Signal<Void, TranslationError>] = []
for (peerId, messageIds) in messagesIdsGroupedByPeerId(messageIds) {
signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, toLang: toLang))
signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: enableLocalIfPossible))
}
return combineLatest(signals)
|> ignoreValues
}
private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Void, TranslationError> {
public protocol ExperimentalInternalTranslationService: AnyObject {
func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError>
}
public var engineExperimentalInternalTranslationService: ExperimentalInternalTranslationService?
private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String, enableLocalIfPossible: Bool) -> Signal<Void, TranslationError> {
return account.postbox.transaction { transaction -> (Api.InputPeer?, [Message]) in
return (transaction.getPeer(peerId).flatMap(apiInputPeer), messageIds.compactMap({ transaction.getMessage($0) }))
}
@ -132,21 +138,58 @@ private func _internal_translateMessagesByPeerId(account: Account, peerId: Engin
if id.isEmpty {
msgs = .single(nil)
} else {
msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang))
|> map(Optional.init)
|> mapError { error -> TranslationError in
if error.errorDescription.hasPrefix("FLOOD_WAIT") {
return .limitExceeded
} else if error.errorDescription == "MSG_ID_INVALID" {
return .invalidMessageId
} else if error.errorDescription == "INPUT_TEXT_EMPTY" {
return .textIsEmpty
} else if error.errorDescription == "INPUT_TEXT_TOO_LONG" {
return .textTooLong
} else if error.errorDescription == "TO_LANG_INVALID" {
return .invalidLanguage
} else {
return .generic
if enableLocalIfPossible, let engineExperimentalInternalTranslationService, let fromLang {
msgs = account.postbox.transaction { transaction -> [MessageId: String] in
var texts: [MessageId: String] = [:]
for messageId in messageIds {
if let message = transaction.getMessage(messageId) {
texts[message.id] = message.text
}
}
return texts
}
|> castError(TranslationError.self)
|> mapToSignal { messageTexts -> Signal<Api.messages.TranslatedText?, TranslationError> in
var mappedTexts: [AnyHashable: String] = [:]
for (id, text) in messageTexts {
mappedTexts[AnyHashable(id)] = text
}
return engineExperimentalInternalTranslationService.translate(texts: mappedTexts, fromLang: fromLang, toLang: toLang)
|> castError(TranslationError.self)
|> mapToSignal { resultTexts -> Signal<Api.messages.TranslatedText?, TranslationError> in
guard let resultTexts else {
return .fail(.generic)
}
var result: [Api.TextWithEntities] = []
for messageId in messageIds {
if let text = resultTexts[AnyHashable(messageId)] {
result.append(.textWithEntities(text: text, entities: []))
} else if let text = messageTexts[messageId] {
result.append(.textWithEntities(text: text, entities: []))
} else {
result.append(.textWithEntities(text: "", entities: []))
}
}
return .single(.translateResult(result: result))
}
}
} else {
msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang))
|> map(Optional.init)
|> mapError { error -> TranslationError in
if error.errorDescription.hasPrefix("FLOOD_WAIT") {
return .limitExceeded
} else if error.errorDescription == "MSG_ID_INVALID" {
return .invalidMessageId
} else if error.errorDescription == "INPUT_TEXT_EMPTY" {
return .textIsEmpty
} else if error.errorDescription == "INPUT_TEXT_TOO_LONG" {
return .textTooLong
} else if error.errorDescription == "TO_LANG_INVALID" {
return .invalidLanguage
} else {
return .generic
}
}
}
}

View File

@ -42,6 +42,7 @@ import MediaEditor
import TelegramUIDeclareEncodables
import ContextMenuScreen
import MetalEngine
import TranslateUI
#if canImport(AppCenter)
import AppCenter
@ -362,6 +363,12 @@ private func extractAccountManagerState(records: AccountRecordsView<TelegramAcco
UIDevice.current.isBatteryMonitoringEnabled = true
}
#if DEBUG
if #available(iOS 18.0, *) {
let translationService = ExperimentalInternalTranslationServiceImpl(view: hostView.containerView)
engineExperimentalInternalTranslationService = translationService
}
#endif
let clearNotificationsManager = ClearNotificationsManager(getNotificationIds: { completion in
if #available(iOS 10.0, *) {

View File

@ -719,7 +719,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
private let clientId: Atomic<Int32>
private var toLang: String?
private var translationLang: (fromLang: String?, toLang: String)?
private var allowDustEffect: Bool = true
private var dustEffectLayer: DustEffectLayer?
@ -838,13 +838,13 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
context?.account.viewTracker.refreshStoriesForMessageIds(messageIds: Set(messageIds.map(\.messageId)))
}
self.translationProcessingManager.process = { [weak self, weak context] messageIds in
if let context = context, let toLang = self?.toLang {
let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), toLang: toLang).startStandalone()
if let context = context, let translationLang = self?.translationLang {
let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), fromLang: translationLang.fromLang, toLang: translationLang.toLang).startStandalone()
}
}
self.factCheckProcessingManager.process = { [weak self, weak context] messageIds in
if let context = context, let toLang = self?.toLang {
let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), toLang: toLang).startStandalone()
if let context = context, let translationLang = self?.translationLang {
let _ = translateMessageIds(context: context, messageIds: Array(messageIds.map(\.messageId)), fromLang: translationLang.fromLang, toLang: translationLang.toLang).startStandalone()
}
}
@ -1848,17 +1848,17 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
providedByGroupBoost: audioTranscriptionProvidedByBoost
)
var translateToLanguage: String?
var translateToLanguage: (fromLang: String, toLang: String)?
if let translationState, isPremium && translationState.isEnabled {
var languageCode = translationState.toLang ?? chatPresentationData.strings.baseLanguageCode
let rawSuffix = "-raw"
if languageCode.hasSuffix(rawSuffix) {
languageCode = String(languageCode.dropLast(rawSuffix.count))
}
translateToLanguage = normalizeTranslationLanguage(languageCode)
translateToLanguage = (normalizeTranslationLanguage(translationState.fromLang), normalizeTranslationLanguage(languageCode))
}
let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, preferredStoryHighQuality: preferredStoryHighQuality, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: savedMessageTags, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels, audioTranscriptionTrial: audioTranscriptionTrial, chatThemes: chatThemes, deviceContactsNumbers: deviceContactsNumbers, isInline: !rotated, showSensitiveContent: contentSettings.ignoreContentRestrictionReasons.contains("sensitive"))
let associatedData = extractAssociatedData(chatLocation: chatLocation, view: view, automaticDownloadNetworkType: networkType, preferredStoryHighQuality: preferredStoryHighQuality, animatedEmojiStickers: animatedEmojiStickers, additionalAnimatedEmojiStickers: additionalAnimatedEmojiStickers, subject: subject, currentlyPlayingMessageId: currentlyPlayingMessageIdAndType?.0, isCopyProtectionEnabled: isCopyProtectionEnabled, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: savedMessageTags, defaultReaction: defaultReaction, isPremium: isPremium, alwaysDisplayTranscribeButton: alwaysDisplayTranscribeButton, accountPeer: accountPeer, topicAuthorId: topicAuthorId, hasBots: chatHasBots, translateToLanguage: translateToLanguage?.toLang, maxReadStoryId: maxReadStoryId, recommendedChannels: recommendedChannels, audioTranscriptionTrial: audioTranscriptionTrial, chatThemes: chatThemes, deviceContactsNumbers: deviceContactsNumbers, isInline: !rotated, showSensitiveContent: contentSettings.ignoreContentRestrictionReasons.contains("sensitive"))
var includeEmbeddedSavedChatInfo = false
if case let .replyThread(message) = chatLocation, message.peerId == context.account.peerId, !rotated {
@ -1958,7 +1958,11 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
var scrollAnimationCurve: ListViewAnimationCurve? = nil
if let strongSelf = self, case .default = source {
strongSelf.toLang = translateToLanguage
if let translateToLanguage {
strongSelf.translationLang = (fromLang: translateToLanguage.fromLang, toLang: translateToLanguage.toLang)
} else {
strongSelf.translationLang = nil
}
if strongSelf.appliedScrollToMessageId == nil, let scrollToMessageId = scrollToMessageId {
updatedScrollPosition = .index(subject: MessageHistoryScrollToSubject(index: .message(scrollToMessageId), quote: nil), position: .center(.top), directionHint: .Up, animated: true, highlight: false, displayLink: true, setupReply: false)
scrollAnimationCurve = .Spring(duration: 0.4)

View File

@ -72,7 +72,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode {
private var currentLayout: (CGFloat, CGFloat, CGFloat)?
private var currentMessage: ChatPinnedMessage?
private var previousMediaReference: AnyMediaReference?
private var currentTranslateToLanguage: String?
private var currentTranslateToLanguage: (fromLang: String, toLang: String)?
private let translationDisposable = MetaDisposable()
private var isReplyThread: Bool = false
@ -496,21 +496,21 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode {
self.clippingContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight))
var translateToLanguage: String?
var translateToLanguage: (fromLang: String, toLang: String)?
if let translationState = interfaceState.translationState, translationState.isEnabled {
translateToLanguage = normalizeTranslationLanguage(translationState.toLang)
translateToLanguage = (normalizeTranslationLanguage(translationState.fromLang), normalizeTranslationLanguage(translationState.toLang))
}
var currentTranslateToLanguageUpdated = false
if self.currentTranslateToLanguage != translateToLanguage {
if self.currentTranslateToLanguage?.fromLang != translateToLanguage?.fromLang || self.currentTranslateToLanguage?.toLang != translateToLanguage?.toLang {
self.currentTranslateToLanguage = translateToLanguage
currentTranslateToLanguageUpdated = true
}
if currentTranslateToLanguageUpdated || messageUpdated, let message = interfaceState.pinnedMessage?.message {
if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == translateToLanguage {
} else if let translateToLanguage {
self.translationDisposable.set(translateMessageIds(context: self.context, messageIds: [message.id], toLang: translateToLanguage).startStrict())
if let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, translation.toLang == translateToLanguage?.toLang {
} else if let translateToLanguage {
self.translationDisposable.set(translateMessageIds(context: self.context, messageIds: [message.id], fromLang: translateToLanguage.fromLang, toLang: translateToLanguage.toLang).startStrict())
}
}
@ -522,7 +522,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode {
if let currentMessage = self.currentMessage, let currentLayout = self.currentLayout {
self.dustNode?.update(revealed: false, animated: false)
self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread, translateToLanguage: translateToLanguage)
self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread, translateToLanguage: translateToLanguage?.toLang)
}
}

View File

@ -60,6 +60,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
public var disableReloginTokens: Bool
public var liveStreamV2: Bool
public var dynamicStreaming: Bool
public var enableLocalTranslation: Bool
public static var defaultSettings: ExperimentalUISettings {
return ExperimentalUISettings(
@ -97,7 +98,8 @@ public struct ExperimentalUISettings: Codable, Equatable {
allowWebViewInspection: false,
disableReloginTokens: false,
liveStreamV2: false,
dynamicStreaming: false
dynamicStreaming: false,
enableLocalTranslation: false
)
}
@ -136,7 +138,8 @@ public struct ExperimentalUISettings: Codable, Equatable {
allowWebViewInspection: Bool,
disableReloginTokens: Bool,
liveStreamV2: Bool,
dynamicStreaming: Bool
dynamicStreaming: Bool,
enableLocalTranslation: Bool
) {
self.keepChatNavigationStack = keepChatNavigationStack
self.skipReadHistory = skipReadHistory
@ -173,6 +176,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
self.disableReloginTokens = disableReloginTokens
self.liveStreamV2 = liveStreamV2
self.dynamicStreaming = dynamicStreaming
self.enableLocalTranslation = enableLocalTranslation
}
public init(from decoder: Decoder) throws {
@ -213,6 +217,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
self.disableReloginTokens = try container.decodeIfPresent(Bool.self, forKey: "disableReloginTokens") ?? false
self.liveStreamV2 = try container.decodeIfPresent(Bool.self, forKey: "liveStreamV2") ?? false
self.dynamicStreaming = try container.decodeIfPresent(Bool.self, forKey: "dynamicStreaming") ?? false
self.enableLocalTranslation = try container.decodeIfPresent(Bool.self, forKey: "enableLocalTranslation") ?? false
}
public func encode(to encoder: Encoder) throws {
@ -253,6 +258,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
try container.encode(self.disableReloginTokens, forKey: "disableReloginTokens")
try container.encode(self.liveStreamV2, forKey: "liveStreamV2")
try container.encode(self.dynamicStreaming, forKey: "dynamicStreaming")
try container.encode(self.enableLocalTranslation, forKey: "enableLocalTranslation")
}
}

View File

@ -117,7 +117,7 @@ public func updateChatTranslationStateInteractively(engine: TelegramEngine, peer
@available(iOS 12.0, *)
private let languageRecognizer = NLLanguageRecognizer()
public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Never, NoError> {
public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], fromLang: String?, toLang: String) -> Signal<Never, NoError> {
return context.account.postbox.transaction { transaction -> Signal<Never, NoError> in
var messageIdsToTranslate: [EngineMessage.Id] = []
var messageIdsSet = Set<EngineMessage.Id>()
@ -159,7 +159,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess
}
}
}
return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, toLang: toLang)
return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, fromLang: fromLang, toLang: toLang, enableLocalIfPossible: context.sharedContext.immediateExperimentalUISettings.enableLocalTranslation)
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}

View File

@ -5,6 +5,9 @@ import SwiftSignalKit
import AccountContext
import NaturalLanguage
import TelegramCore
import SwiftUI
import Translation
import Combine
// Incuding at least one Objective-C class in a swift file ensures that it doesn't get stripped by the linker
private final class LinkHelperClass: NSObject {
@ -213,3 +216,190 @@ public func systemLanguageCodes() -> [String] {
}
return languages
}
@available(iOS 13.0, *)
class ExternalTranslationTrigger: ObservableObject {
@Published var shouldInvalidate: Int = 0
}
@available(iOS 18.0, *)
private struct TranslationViewImpl: View {
@State private var configuration: TranslationSession.Configuration?
@ObservedObject var externalCondition: ExternalTranslationTrigger
private let taskContainer: Atomic<ExperimentalInternalTranslationServiceImpl.TranslationTaskContainer>
init(externalCondition: ExternalTranslationTrigger, taskContainer: Atomic<ExperimentalInternalTranslationServiceImpl.TranslationTaskContainer>) {
self.externalCondition = externalCondition
self.taskContainer = taskContainer
}
var body: some View {
Text("ABC")
.onChange(of: self.externalCondition.shouldInvalidate) { _ in
let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in
if let firstTask = taskContainer.tasks.first {
return (firstTask.fromLang, firstTask.toLang)
} else {
return nil
}
}
if let firstTaskLanguagePair {
if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 {
self.configuration?.invalidate()
} else {
self.configuration = .init(
source: Locale.Language(identifier: firstTaskLanguagePair.0),
target: Locale.Language(identifier: firstTaskLanguagePair.1)
)
}
}
}
.translationTask(self.configuration, action: { session in
var task: ExperimentalInternalTranslationServiceImpl.TranslationTask?
task = self.taskContainer.with { taskContainer -> ExperimentalInternalTranslationServiceImpl.TranslationTask? in
if !taskContainer.tasks.isEmpty {
return taskContainer.tasks.removeFirst()
} else {
return nil
}
}
guard let task else {
return
}
do {
var nextClientIdentifier: Int = 0
var clientIdentifierMap: [String: AnyHashable] = [:]
let translationRequests = task.texts.map { key, value in
let id = nextClientIdentifier
nextClientIdentifier += 1
clientIdentifierMap["\(id)"] = key
return TranslationSession.Request(sourceText: value, clientIdentifier: "\(id)")
}
let responses = try await session.translations(from: translationRequests)
var resultMap: [AnyHashable: String] = [:]
for response in responses {
if let clientIdentifier = response.clientIdentifier, let originalKey = clientIdentifierMap[clientIdentifier] {
resultMap[originalKey] = "<L>\(response.targetText)"
}
}
task.completion(resultMap)
} catch let e {
print("Translation error: \(e)")
task.completion(nil)
}
let firstTaskLanguagePair = self.taskContainer.with { taskContainer -> (String, String)? in
if let firstTask = taskContainer.tasks.first {
return (firstTask.fromLang, firstTask.toLang)
} else {
return nil
}
}
if let firstTaskLanguagePair {
if let configuration = self.configuration, configuration.source?.languageCode?.identifier == firstTaskLanguagePair.0, configuration.target?.languageCode?.identifier == firstTaskLanguagePair.1 {
self.configuration?.invalidate()
} else {
self.configuration = .init(
source: Locale.Language(identifier: firstTaskLanguagePair.0),
target: Locale.Language(identifier: firstTaskLanguagePair.1)
)
}
}
})
}
}
@available(iOS 18.0, *)
public final class ExperimentalInternalTranslationServiceImpl: ExperimentalInternalTranslationService {
fileprivate final class TranslationTask {
let id: Int
let texts: [AnyHashable: String]
let fromLang: String
let toLang: String
let completion: ([AnyHashable: String]?) -> Void
init(id: Int, texts: [AnyHashable: String], fromLang: String, toLang: String, completion: @escaping ([AnyHashable: String]?) -> Void) {
self.id = id
self.texts = texts
self.fromLang = fromLang
self.toLang = toLang
self.completion = completion
}
}
fileprivate final class TranslationTaskContainer {
var tasks: [TranslationTask] = []
init() {
}
}
private final class Impl {
private let hostingController: UIViewController
private let taskContainer = Atomic(value: TranslationTaskContainer())
private let taskTrigger = ExternalTranslationTrigger()
private var nextId: Int = 0
init(view: UIView) {
self.hostingController = UIHostingController(rootView: TranslationViewImpl(
externalCondition: self.taskTrigger,
taskContainer: self.taskContainer
))
view.addSubview(self.hostingController.view)
}
func translate(texts: [AnyHashable: String], fromLang: String, toLang: String, onResult: @escaping ([AnyHashable: String]?) -> Void) -> Disposable {
let id = self.nextId
self.nextId += 1
self.taskContainer.with { taskContainer in
taskContainer.tasks.append(TranslationTask(
id: id,
texts: texts,
fromLang: fromLang,
toLang: toLang,
completion: { result in
onResult(result)
}
))
}
self.taskTrigger.shouldInvalidate += 1
return ActionDisposable { [weak self] in
Queue.mainQueue().async {
guard let self else {
return
}
self.taskContainer.with { taskContainer in
taskContainer.tasks.removeAll(where: { $0.id == id })
}
}
}
}
}
private let impl: QueueLocalObject<Impl>
public init(view: UIView) {
self.impl = QueueLocalObject(queue: .mainQueue(), generate: {
return Impl(view: view)
})
}
public func translate(texts: [AnyHashable: String], fromLang: String, toLang: String) -> Signal<[AnyHashable: String]?, NoError> {
return self.impl.signalWith { impl, subscriber in
return impl.translate(texts: texts, fromLang: fromLang, toLang: toLang, onResult: { result in
subscriber.putNext(result)
subscriber.putCompletion()
})
}
}
}