mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
Metrics
This commit is contained in:
parent
8605549176
commit
efb828e61b
5 changed files with 777 additions and 4 deletions
|
|
@ -1722,8 +1722,8 @@ public extension TelegramEngine {
|
|||
viewId: metric.id,
|
||||
timeInViewMs: Int32(metric.timeInViewMs),
|
||||
activeTimeInViewMs: Int32(metric.activeTimeInViewMs),
|
||||
heightToViewportRatioPermille: Int32(metric.heightToViewportRatio * 1000.0),
|
||||
seenRangeRatioPermille: Int32(metric.seenRangeRatio * 1000.0)
|
||||
heightToViewportRatioPermille: Int32((metric.heightToViewportRatio * 1000.0).rounded()),
|
||||
seenRangeRatioPermille: Int32((metric.seenRangeRatio * 1000.0).rounded())
|
||||
))
|
||||
}
|
||||
))
|
||||
|
|
@ -1733,6 +1733,21 @@ public extension TelegramEngine {
|
|||
|> ignoreValues
|
||||
}
|
||||
}
|
||||
|
||||
public func reportMusicListened(file: FileMediaReference, duration: Int) -> Signal<Never, NoError> {
|
||||
guard let resource = file.media.resource as? CloudDocumentMediaResource, let reference = file.resourceReference(file.media.resource) as? TelegramCloudMediaResourceWithFileReference else {
|
||||
return .complete()
|
||||
}
|
||||
return self.account.network.request(Api.functions.messages.reportMusicListen(id: .inputDocument(Api.InputDocument.Cons_inputDocument(
|
||||
id: resource.fileId,
|
||||
accessHash: resource.accessHash,
|
||||
fileReference: Buffer(data: reference.fileReference ?? Data())
|
||||
)), listenedDuration: Int32(clamping: duration)))
|
||||
|> `catch` { _ -> Signal<Api.Bool, NoError> in
|
||||
return .single(.boolFalse)
|
||||
}
|
||||
|> ignoreValues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,449 @@
|
|||
import Foundation
|
||||
import SwiftSignalKit
|
||||
import Postbox
|
||||
|
||||
public final class MessageReadMetricsTracker {
|
||||
public struct VisibleMessageEntry {
|
||||
public let messageId: EngineMessage.Id
|
||||
public let visibleTopPx: CGFloat
|
||||
public let visibleBottomPx: CGFloat
|
||||
public let postHeightPx: CGFloat
|
||||
public let viewportHeightPx: CGFloat
|
||||
|
||||
public init(messageId: EngineMessage.Id, visibleTopPx: CGFloat, visibleBottomPx: CGFloat, postHeightPx: CGFloat, viewportHeightPx: CGFloat) {
|
||||
self.messageId = messageId
|
||||
self.visibleTopPx = visibleTopPx
|
||||
self.visibleBottomPx = visibleBottomPx
|
||||
self.postHeightPx = postHeightPx
|
||||
self.viewportHeightPx = viewportHeightPx
|
||||
}
|
||||
}
|
||||
|
||||
private enum PhaseState {
|
||||
case graceStart
|
||||
case tracking
|
||||
case graceEnd
|
||||
case paused
|
||||
}
|
||||
|
||||
private static let kGracePeriod: Double = 0.3
|
||||
private static let kMinViewTimeMs: Int = 300
|
||||
private static let kUserActivityTimeout: Double = 15.0
|
||||
private static let kMaxPhaseDuration: Double = 300.0
|
||||
private static let kHeartbeatInterval: Double = 0.1
|
||||
|
||||
private struct TrackingPhase {
|
||||
let viewId: Int64
|
||||
let messageId: EngineMessage.Id
|
||||
var state: PhaseState
|
||||
|
||||
var totalViewTimeMs: Int = 0
|
||||
var activeViewTimeMs: Int = 0
|
||||
var seenTopPx: CGFloat = CGFloat.greatestFiniteMagnitude
|
||||
var seenBottomPx: CGFloat = 0.0
|
||||
var postHeightPx: CGFloat = 0.0
|
||||
var viewportHeightPx: CGFloat = 0.0
|
||||
|
||||
var lastTickTime: CFAbsoluteTime?
|
||||
let phaseStartTime: CFAbsoluteTime
|
||||
var graceTimer: SwiftSignalKit.Timer?
|
||||
var graceDeadline: CFAbsoluteTime?
|
||||
|
||||
var stateBeforePause: PhaseState?
|
||||
var graceRemainingAtPause: Double?
|
||||
|
||||
init(messageId: EngineMessage.Id) {
|
||||
self.viewId = Int64.random(in: Int64.min ... Int64.max)
|
||||
self.messageId = messageId
|
||||
self.state = .graceStart
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
self.phaseStartTime = now
|
||||
self.lastTickTime = now
|
||||
}
|
||||
|
||||
mutating func updateSeenRange(visibleTopPx: CGFloat, visibleBottomPx: CGFloat, postHeightPx: CGFloat, viewportHeightPx: CGFloat) {
|
||||
self.seenTopPx = min(self.seenTopPx, visibleTopPx)
|
||||
self.seenBottomPx = max(self.seenBottomPx, visibleBottomPx)
|
||||
self.postHeightPx = postHeightPx
|
||||
self.viewportHeightPx = viewportHeightPx
|
||||
}
|
||||
|
||||
mutating func flushTime(now: CFAbsoluteTime, lastActivityTime: CFAbsoluteTime) {
|
||||
if let lastTick = self.lastTickTime {
|
||||
let deltaMs = Int((now - lastTick) * 1000.0)
|
||||
self.totalViewTimeMs += deltaMs
|
||||
if (now - lastActivityTime) < kUserActivityTimeout {
|
||||
self.activeViewTimeMs += deltaMs
|
||||
}
|
||||
}
|
||||
self.lastTickTime = now
|
||||
}
|
||||
|
||||
mutating func stopTicking(now: CFAbsoluteTime, lastActivityTime: CFAbsoluteTime) {
|
||||
if let lastTick = self.lastTickTime {
|
||||
let deltaMs = Int((now - lastTick) * 1000.0)
|
||||
self.totalViewTimeMs += deltaMs
|
||||
if (now - lastActivityTime) < kUserActivityTimeout {
|
||||
self.activeViewTimeMs += deltaMs
|
||||
}
|
||||
}
|
||||
self.lastTickTime = nil
|
||||
}
|
||||
|
||||
func buildMetric() -> TelegramMessageReadMetric? {
|
||||
guard self.totalViewTimeMs >= kMinViewTimeMs else {
|
||||
return nil
|
||||
}
|
||||
guard self.postHeightPx > 0.0 && self.viewportHeightPx > 0.0 else {
|
||||
return nil
|
||||
}
|
||||
let heightToViewportRatio = Double(self.postHeightPx / self.viewportHeightPx)
|
||||
var seenRangeRatio: Double = 0.0
|
||||
if self.seenBottomPx > self.seenTopPx {
|
||||
seenRangeRatio = min(1.0, max(0.0, Double((self.seenBottomPx - self.seenTopPx) / self.postHeightPx)))
|
||||
}
|
||||
return TelegramMessageReadMetric(
|
||||
id: self.viewId,
|
||||
messageId: self.messageId,
|
||||
timeInViewMs: self.totalViewTimeMs,
|
||||
activeTimeInViewMs: self.activeViewTimeMs,
|
||||
heightToViewportRatio: heightToViewportRatio,
|
||||
seenRangeRatio: seenRangeRatio
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let pipe = ValuePipe<TelegramMessageReadMetric>()
|
||||
public var completedMetrics: Signal<TelegramMessageReadMetric, NoError> {
|
||||
return self.pipe.signal()
|
||||
}
|
||||
|
||||
private var phases: [EngineMessage.Id: TrackingPhase] = [:]
|
||||
private var isActive: Bool = true
|
||||
private var lastActivityTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()
|
||||
private var heartbeatTimer: SwiftSignalKit.Timer?
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.heartbeatTimer?.invalidate()
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
for (_, phase) in self.phases {
|
||||
phase.graceTimer?.invalidate()
|
||||
var phase = phase
|
||||
if phase.state == .tracking || phase.state == .graceStart {
|
||||
phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime)
|
||||
}
|
||||
if let metric = phase.buildMetric() {
|
||||
self.pipe.putNext(metric)
|
||||
}
|
||||
}
|
||||
self.phases.removeAll()
|
||||
}
|
||||
|
||||
public func updateVisibleMessages(_ entries: [VisibleMessageEntry]) {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
guard self.isActive else { return }
|
||||
|
||||
let visibleIds = Set(entries.map { $0.messageId })
|
||||
|
||||
var toDiscard: [EngineMessage.Id] = []
|
||||
var toBeginGraceEnd: [EngineMessage.Id] = []
|
||||
var toResumeGraceEnd: [EngineMessage.Id] = []
|
||||
for (messageId, phase) in self.phases {
|
||||
if !visibleIds.contains(messageId) {
|
||||
switch phase.state {
|
||||
case .graceStart:
|
||||
toDiscard.append(messageId)
|
||||
case .tracking:
|
||||
toBeginGraceEnd.append(messageId)
|
||||
case .graceEnd:
|
||||
break
|
||||
case .paused:
|
||||
switch phase.stateBeforePause {
|
||||
case .graceStart, .none:
|
||||
toDiscard.append(messageId)
|
||||
case .tracking, .graceEnd:
|
||||
toResumeGraceEnd.append(messageId)
|
||||
case .paused:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for messageId in toDiscard {
|
||||
self.phases[messageId]?.graceTimer?.invalidate()
|
||||
self.phases.removeValue(forKey: messageId)
|
||||
}
|
||||
for messageId in toBeginGraceEnd {
|
||||
self.beginGraceEnd(messageId: messageId)
|
||||
}
|
||||
for messageId in toResumeGraceEnd {
|
||||
self.resumeGraceEnd(messageId: messageId)
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
if var phase = self.phases[entry.messageId] {
|
||||
switch phase.state {
|
||||
case .graceStart, .tracking:
|
||||
phase.updateSeenRange(
|
||||
visibleTopPx: entry.visibleTopPx,
|
||||
visibleBottomPx: entry.visibleBottomPx,
|
||||
postHeightPx: entry.postHeightPx,
|
||||
viewportHeightPx: entry.viewportHeightPx
|
||||
)
|
||||
self.phases[entry.messageId] = phase
|
||||
case .graceEnd:
|
||||
phase.graceTimer?.invalidate()
|
||||
phase.graceTimer = nil
|
||||
phase.graceDeadline = nil
|
||||
phase.state = .tracking
|
||||
phase.lastTickTime = CFAbsoluteTimeGetCurrent()
|
||||
phase.updateSeenRange(
|
||||
visibleTopPx: entry.visibleTopPx,
|
||||
visibleBottomPx: entry.visibleBottomPx,
|
||||
postHeightPx: entry.postHeightPx,
|
||||
viewportHeightPx: entry.viewportHeightPx
|
||||
)
|
||||
self.phases[entry.messageId] = phase
|
||||
self.startHeartbeatIfNeeded()
|
||||
case .paused:
|
||||
self.resumePausedPhase(messageId: entry.messageId, entry: entry)
|
||||
}
|
||||
} else {
|
||||
self.beginGraceStart(messageId: entry.messageId, entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reportUserActivity() {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
self.lastActivityTime = CFAbsoluteTimeGetCurrent()
|
||||
}
|
||||
|
||||
public func setIsActive(_ isActive: Bool) {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
guard self.isActive != isActive else { return }
|
||||
self.isActive = isActive
|
||||
|
||||
if !isActive {
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
for (messageId, phase) in self.phases {
|
||||
var phase = phase
|
||||
switch phase.state {
|
||||
case .tracking:
|
||||
phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime)
|
||||
phase.stateBeforePause = .tracking
|
||||
phase.state = .paused
|
||||
self.phases[messageId] = phase
|
||||
case .graceStart:
|
||||
phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime)
|
||||
if let deadline = phase.graceDeadline {
|
||||
phase.graceRemainingAtPause = max(0, deadline - now)
|
||||
}
|
||||
phase.graceTimer?.invalidate()
|
||||
phase.graceTimer = nil
|
||||
phase.graceDeadline = nil
|
||||
phase.stateBeforePause = .graceStart
|
||||
phase.state = .paused
|
||||
self.phases[messageId] = phase
|
||||
case .graceEnd:
|
||||
if let deadline = phase.graceDeadline {
|
||||
phase.graceRemainingAtPause = max(0, deadline - now)
|
||||
}
|
||||
phase.graceTimer?.invalidate()
|
||||
phase.graceTimer = nil
|
||||
phase.graceDeadline = nil
|
||||
phase.stateBeforePause = .graceEnd
|
||||
phase.state = .paused
|
||||
self.phases[messageId] = phase
|
||||
case .paused:
|
||||
break
|
||||
}
|
||||
}
|
||||
self.heartbeatTimer?.invalidate()
|
||||
self.heartbeatTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func startHeartbeatIfNeeded() {
|
||||
guard self.heartbeatTimer == nil else { return }
|
||||
let hasTrackingPhases = self.phases.values.contains(where: { $0.state == .tracking })
|
||||
guard hasTrackingPhases else { return }
|
||||
let timer = SwiftSignalKit.Timer(timeout: MessageReadMetricsTracker.kHeartbeatInterval, repeat: true, completion: { [weak self] in
|
||||
self?.heartbeatTick()
|
||||
}, queue: Queue.mainQueue())
|
||||
self.heartbeatTimer = timer
|
||||
timer.start()
|
||||
}
|
||||
|
||||
private func stopHeartbeatIfIdle() {
|
||||
let hasTrackingPhases = self.phases.values.contains(where: { $0.state == .tracking })
|
||||
if !hasTrackingPhases {
|
||||
self.heartbeatTimer?.invalidate()
|
||||
self.heartbeatTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func heartbeatTick() {
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
var toFinalize: [EngineMessage.Id] = []
|
||||
for (messageId, phase) in self.phases {
|
||||
guard phase.state == .tracking else { continue }
|
||||
|
||||
var phase = phase
|
||||
phase.flushTime(now: now, lastActivityTime: self.lastActivityTime)
|
||||
|
||||
if (now - phase.phaseStartTime) >= MessageReadMetricsTracker.kMaxPhaseDuration {
|
||||
toFinalize.append(messageId)
|
||||
}
|
||||
self.phases[messageId] = phase
|
||||
}
|
||||
|
||||
for messageId in toFinalize {
|
||||
self.finalizePhase(messageId: messageId)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginGraceStart(messageId: EngineMessage.Id, entry: VisibleMessageEntry) {
|
||||
var phase = TrackingPhase(messageId: messageId)
|
||||
phase.updateSeenRange(
|
||||
visibleTopPx: entry.visibleTopPx,
|
||||
visibleBottomPx: entry.visibleBottomPx,
|
||||
postHeightPx: entry.postHeightPx,
|
||||
viewportHeightPx: entry.viewportHeightPx
|
||||
)
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
phase.graceDeadline = now + MessageReadMetricsTracker.kGracePeriod
|
||||
let timer = SwiftSignalKit.Timer(timeout: MessageReadMetricsTracker.kGracePeriod, repeat: false, completion: { [weak self] in
|
||||
self?.graceStartCompleted(messageId: messageId)
|
||||
}, queue: Queue.mainQueue())
|
||||
phase.graceTimer = timer
|
||||
self.phases[messageId] = phase
|
||||
timer.start()
|
||||
}
|
||||
|
||||
private func graceStartCompleted(messageId: EngineMessage.Id) {
|
||||
guard var phase = self.phases[messageId], phase.state == .graceStart else { return }
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
phase.flushTime(now: now, lastActivityTime: self.lastActivityTime)
|
||||
phase.graceTimer = nil
|
||||
phase.graceDeadline = nil
|
||||
phase.state = .tracking
|
||||
self.phases[messageId] = phase
|
||||
self.startHeartbeatIfNeeded()
|
||||
}
|
||||
|
||||
private func beginGraceEnd(messageId: EngineMessage.Id) {
|
||||
guard var phase = self.phases[messageId], phase.state == .tracking else { return }
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime)
|
||||
phase.state = .graceEnd
|
||||
phase.graceDeadline = now + MessageReadMetricsTracker.kGracePeriod
|
||||
let timer = SwiftSignalKit.Timer(timeout: MessageReadMetricsTracker.kGracePeriod, repeat: false, completion: { [weak self] in
|
||||
self?.graceEndCompleted(messageId: messageId)
|
||||
}, queue: Queue.mainQueue())
|
||||
phase.graceTimer = timer
|
||||
self.phases[messageId] = phase
|
||||
timer.start()
|
||||
self.stopHeartbeatIfIdle()
|
||||
}
|
||||
|
||||
private func resumeGraceEnd(messageId: EngineMessage.Id) {
|
||||
guard var phase = self.phases[messageId], phase.state == .paused else { return }
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
let remaining: Double
|
||||
if phase.stateBeforePause == .graceEnd {
|
||||
remaining = phase.graceRemainingAtPause ?? MessageReadMetricsTracker.kGracePeriod
|
||||
} else {
|
||||
remaining = MessageReadMetricsTracker.kGracePeriod
|
||||
}
|
||||
phase.stateBeforePause = nil
|
||||
phase.graceRemainingAtPause = nil
|
||||
phase.state = .graceEnd
|
||||
phase.graceDeadline = now + remaining
|
||||
let timer = SwiftSignalKit.Timer(timeout: remaining, repeat: false, completion: { [weak self] in
|
||||
self?.graceEndCompleted(messageId: messageId)
|
||||
}, queue: Queue.mainQueue())
|
||||
phase.graceTimer = timer
|
||||
self.phases[messageId] = phase
|
||||
timer.start()
|
||||
}
|
||||
|
||||
private func graceEndCompleted(messageId: EngineMessage.Id) {
|
||||
if let phase = self.phases[messageId], phase.state == .graceEnd {
|
||||
self.finalizePhase(messageId: messageId)
|
||||
}
|
||||
}
|
||||
|
||||
private func resumePausedPhase(messageId: EngineMessage.Id, entry: VisibleMessageEntry) {
|
||||
guard var phase = self.phases[messageId], phase.state == .paused else { return }
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
if (now - phase.phaseStartTime) >= MessageReadMetricsTracker.kMaxPhaseDuration {
|
||||
self.finalizePhase(messageId: messageId)
|
||||
return
|
||||
}
|
||||
|
||||
let resumeTo = phase.stateBeforePause ?? .tracking
|
||||
phase.stateBeforePause = nil
|
||||
|
||||
switch resumeTo {
|
||||
case .tracking:
|
||||
phase.state = .tracking
|
||||
phase.lastTickTime = now
|
||||
phase.graceRemainingAtPause = nil
|
||||
phase.updateSeenRange(
|
||||
visibleTopPx: entry.visibleTopPx,
|
||||
visibleBottomPx: entry.visibleBottomPx,
|
||||
postHeightPx: entry.postHeightPx,
|
||||
viewportHeightPx: entry.viewportHeightPx
|
||||
)
|
||||
self.phases[messageId] = phase
|
||||
self.startHeartbeatIfNeeded()
|
||||
case .graceStart:
|
||||
let remaining = phase.graceRemainingAtPause ?? MessageReadMetricsTracker.kGracePeriod
|
||||
phase.graceRemainingAtPause = nil
|
||||
phase.state = .graceStart
|
||||
phase.lastTickTime = now
|
||||
phase.graceDeadline = now + remaining
|
||||
let timer = SwiftSignalKit.Timer(timeout: remaining, repeat: false, completion: { [weak self] in
|
||||
self?.graceStartCompleted(messageId: messageId)
|
||||
}, queue: Queue.mainQueue())
|
||||
phase.graceTimer = timer
|
||||
phase.updateSeenRange(
|
||||
visibleTopPx: entry.visibleTopPx,
|
||||
visibleBottomPx: entry.visibleBottomPx,
|
||||
postHeightPx: entry.postHeightPx,
|
||||
viewportHeightPx: entry.viewportHeightPx
|
||||
)
|
||||
self.phases[messageId] = phase
|
||||
timer.start()
|
||||
case .graceEnd:
|
||||
phase.graceRemainingAtPause = nil
|
||||
phase.state = .tracking
|
||||
phase.lastTickTime = now
|
||||
phase.updateSeenRange(
|
||||
visibleTopPx: entry.visibleTopPx,
|
||||
visibleBottomPx: entry.visibleBottomPx,
|
||||
postHeightPx: entry.postHeightPx,
|
||||
viewportHeightPx: entry.viewportHeightPx
|
||||
)
|
||||
self.phases[messageId] = phase
|
||||
self.startHeartbeatIfNeeded()
|
||||
case .paused:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizePhase(messageId: EngineMessage.Id) {
|
||||
guard let phase = self.phases.removeValue(forKey: messageId) else { return }
|
||||
phase.graceTimer?.invalidate()
|
||||
if let metric = phase.buildMetric() {
|
||||
self.pipe.putNext(metric)
|
||||
}
|
||||
self.stopHeartbeatIfIdle()
|
||||
}
|
||||
}
|
||||
|
|
@ -671,6 +671,11 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
public private(set) var loadStateUpdated: ((ChatHistoryNodeLoadState, Bool) -> Void)?
|
||||
private var additionalLoadStateUpdated: [(ChatHistoryNodeLoadState, Bool) -> Void] = []
|
||||
|
||||
private var messageReadMetricsTracker: MessageReadMetricsTracker?
|
||||
private var messageReadMetricsTrackerDisposable: Disposable?
|
||||
private var messageReadMetricsTrackerPendingMetrics: [TelegramMessageReadMetric] = []
|
||||
private var messageReadMetricsTrackerPendingMetricTimer: Foundation.Timer?
|
||||
|
||||
public private(set) var hasAtLeast3Messages: Bool = false
|
||||
public var hasAtLeast3MessagesUpdated: ((Bool) -> Void)?
|
||||
|
||||
|
|
@ -1139,6 +1144,9 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
if let maxMessage {
|
||||
strongSelf.updateMaxVisibleReadIncomingMessageIndex(maxMessage)
|
||||
}
|
||||
|
||||
strongSelf.messageReadMetricsTracker?.reportUserActivity()
|
||||
strongSelf.updateMessageReadTracker()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1239,6 +1247,16 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
self.genericReactionEffectDisposable?.dispose()
|
||||
self.adMessagesDisposable?.dispose()
|
||||
self.presentationDataDisposable?.dispose()
|
||||
self.messageReadMetricsTrackerPendingMetricTimer?.invalidate()
|
||||
self.messageReadMetricsTrackerDisposable?.dispose()
|
||||
self.messageReadMetricsTracker = nil
|
||||
if !self.messageReadMetricsTrackerPendingMetrics.isEmpty {
|
||||
let metrics = self.messageReadMetricsTrackerPendingMetrics
|
||||
self.messageReadMetricsTrackerPendingMetrics.removeAll()
|
||||
if !metrics.isEmpty, let peerId = self.chatLocation.peerId {
|
||||
let _ = self.context.engine.messages.reportPeerReadMetrics(peerId: peerId, metrics: metrics).startStandalone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func updateTag(tag: HistoryViewInputTag?) {
|
||||
|
|
@ -2426,6 +2444,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
strongSelf.canReadHistoryValue = value
|
||||
strongSelf.controllerInteraction.canReadHistory = value
|
||||
strongSelf.updateReadHistoryActions()
|
||||
strongSelf.messageReadMetricsTracker?.setIsActive(strongSelf.canReadHistoryValue)
|
||||
|
||||
if strongSelf.canReadHistoryValue && !strongSelf.suspendReadingReactions && !strongSelf.messageIdsScheduledForMarkAsSeen.isEmpty {
|
||||
let messageIds = strongSelf.messageIdsScheduledForMarkAsSeen
|
||||
|
|
@ -3325,6 +3344,87 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
self.isInteractivelyScrollingPromise.set(true)
|
||||
self.isInteractivelyScrollingPromise.set(false)
|
||||
}
|
||||
|
||||
self.updateMessageReadTracker()
|
||||
}
|
||||
|
||||
private func updateMessageReadTracker() {
|
||||
guard let historyView = self.historyView?.originalView else {
|
||||
return
|
||||
}
|
||||
var channelPeer: TelegramChannel?
|
||||
for entry in historyView.additionalData {
|
||||
if case let .peer(_, value) = entry {
|
||||
if let channel = value as? TelegramChannel, case .broadcast = channel.info {
|
||||
channelPeer = channel
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let channelPeer else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageReadMetricsTracker: MessageReadMetricsTracker
|
||||
if let current = self.messageReadMetricsTracker {
|
||||
messageReadMetricsTracker = current
|
||||
} else {
|
||||
messageReadMetricsTracker = MessageReadMetricsTracker()
|
||||
self.messageReadMetricsTracker = messageReadMetricsTracker
|
||||
|
||||
self.messageReadMetricsTrackerDisposable = (messageReadMetricsTracker.completedMetrics |> deliverOnMainQueue).startStrict(next: { [weak self] metric in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.messageReadMetricsTrackerPendingMetrics.append(metric)
|
||||
|
||||
if self.messageReadMetricsTrackerPendingMetricTimer == nil {
|
||||
self.messageReadMetricsTrackerPendingMetricTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false, block: { [weak self] timer in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.messageReadMetricsTrackerPendingMetricTimer === timer {
|
||||
self.messageReadMetricsTrackerPendingMetricTimer = nil
|
||||
}
|
||||
let metrics = self.messageReadMetricsTrackerPendingMetrics
|
||||
self.messageReadMetricsTrackerPendingMetrics.removeAll()
|
||||
|
||||
if !metrics.isEmpty {
|
||||
let _ = self.context.engine.messages.reportPeerReadMetrics(peerId: channelPeer.id, metrics: metrics).startStandalone()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
messageReadMetricsTracker.setIsActive(self.canReadHistoryValue)
|
||||
|
||||
var visibleMessages: [MessageReadMetricsTracker.VisibleMessageEntry] = []
|
||||
let viewportMinY = self.insets.top
|
||||
let viewportMaxY = max(1.0, self.visibleSize.height - self.insets.bottom)
|
||||
let viewportHeight = viewportMaxY - viewportMinY
|
||||
self.forEachVisibleMessageItemNode { itemView in
|
||||
guard let item = itemView.item else {
|
||||
return
|
||||
}
|
||||
let messageId = item.message.id
|
||||
if messageId.namespace != Namespaces.Message.Cloud {
|
||||
return
|
||||
}
|
||||
let frame = itemView.frame
|
||||
let visibleStart = max(frame.minY, viewportMinY)
|
||||
let visibleEnd = min(frame.maxY, viewportMaxY)
|
||||
if visibleStart >= visibleEnd {
|
||||
return
|
||||
}
|
||||
visibleMessages.append(MessageReadMetricsTracker.VisibleMessageEntry(
|
||||
messageId: messageId,
|
||||
visibleTopPx: visibleStart - frame.minY,
|
||||
visibleBottomPx: visibleEnd - frame.minY,
|
||||
postHeightPx: frame.height,
|
||||
viewportHeightPx: viewportHeight
|
||||
))
|
||||
}
|
||||
messageReadMetricsTracker.updateVisibleMessages(visibleMessages)
|
||||
}
|
||||
|
||||
public func scrollScreenToTop() {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public final class MediaManagerImpl: NSObject, MediaManager {
|
|||
} |> deliverOnMainQueue)
|
||||
} else {
|
||||
self.musicMediaPlayerStateValue.set(.single(nil))
|
||||
self.musicListenTracker?.playerClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -186,7 +187,10 @@ public final class MediaManagerImpl: NSObject, MediaManager {
|
|||
private let globalControlsArtwork = Promise<(Account, SharedMediaPlaybackAlbumArt)?>(nil)
|
||||
private let globalControlsStatusDisposable = MetaDisposable()
|
||||
private let globalAudioSessionForegroundDisposable = MetaDisposable()
|
||||
|
||||
|
||||
private var musicListenTracker: MusicListenTracker?
|
||||
private let musicListenTrackingDisposable = MetaDisposable()
|
||||
|
||||
public let universalVideoManager: UniversalVideoManager = UniversalVideoManagerImpl()
|
||||
|
||||
public let galleryHiddenMediaManager: GalleryHiddenMediaManager = GalleryHiddenMediaManagerImpl()
|
||||
|
|
@ -429,7 +433,12 @@ public final class MediaManagerImpl: NSObject, MediaManager {
|
|||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
self.musicListenTrackingDisposable.set((self.musicMediaPlayerState
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak self] stateAndType in
|
||||
self?.musicListenTracker?.update(with: stateAndType)
|
||||
}))
|
||||
|
||||
self.globalAudioSessionForegroundDisposable.set((shouldKeepAudioSession |> deliverOnMainQueue).startStrict(next: { [weak self] value in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
|
|
@ -446,6 +455,7 @@ public final class MediaManagerImpl: NSObject, MediaManager {
|
|||
self.globalControlsStatusDisposable.dispose()
|
||||
self.setPlaylistByTypeDisposables.dispose()
|
||||
self.mediaPlaybackStateDisposable.dispose()
|
||||
self.musicListenTrackingDisposable.dispose()
|
||||
self.globalAudioSessionForegroundDisposable.dispose()
|
||||
self.voiceMediaPlayerStateDisposable.dispose()
|
||||
}
|
||||
|
|
@ -550,6 +560,7 @@ public final class MediaManagerImpl: NSObject, MediaManager {
|
|||
strongSelf.musicMediaPlayer?.stop()
|
||||
let musicMediaPlayer = SharedMediaPlayer(context: context, mediaManager: strongSelf, inForeground: strongSelf.inForeground, account: context.account, audioSession: strongSelf.audioSession, overlayMediaManager: strongSelf.overlayMediaManager, playlist: playlist, initialOrder: settings.order, initialLooping: settings.looping, initialPlaybackRate: storedState?.playbackRate ?? .x1, playerIndex: nextPlayerIndex, controlPlaybackWithProximity: false, type: type, continueInstantVideoLoopAfterFinish: true)
|
||||
strongSelf.musicMediaPlayer = musicMediaPlayer
|
||||
strongSelf.musicListenTracker = MusicListenTracker(engine: context.engine)
|
||||
musicMediaPlayer.cancelled = { [weak musicMediaPlayer] in
|
||||
if let strongSelf = self, let musicMediaPlayer = musicMediaPlayer, musicMediaPlayer === strongSelf.musicMediaPlayer {
|
||||
musicMediaPlayer.stop()
|
||||
|
|
|
|||
198
submodules/TelegramUI/Sources/MusicListenTracker.swift
Normal file
198
submodules/TelegramUI/Sources/MusicListenTracker.swift
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import Foundation
|
||||
import SwiftSignalKit
|
||||
import TelegramCore
|
||||
import AccountContext
|
||||
import UniversalMediaPlayer
|
||||
|
||||
final class MusicListenTracker {
|
||||
private let engine: TelegramEngine
|
||||
|
||||
// Current listening session
|
||||
private var currentFileReference: FileMediaReference?
|
||||
private var currentItemStableId: AnyHashable?
|
||||
private var trackDuration: Double = 0
|
||||
|
||||
// Position-based duration accumulation
|
||||
private var accumulatedDuration: Double = 0
|
||||
private var lastPosition: Double = 0
|
||||
private var lastGenerationTimestamp: Double = 0
|
||||
private var lastBaseRate: Double = 1.0
|
||||
private var lastSeekId: Int = 0
|
||||
private var isPlaying: Bool = false
|
||||
|
||||
// Pause tracking
|
||||
private var pauseTimer: SwiftSignalKit.Timer?
|
||||
|
||||
// Disposables
|
||||
private let reportDisposable = MetaDisposable()
|
||||
|
||||
private static let pauseTimeoutSeconds: Double = 60.0
|
||||
|
||||
init(engine: TelegramEngine) {
|
||||
self.engine = engine
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.pauseTimer?.invalidate()
|
||||
self.reportDisposable.dispose()
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Called by MediaManagerImpl with each music player state update.
|
||||
func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
guard let (_, stateOrLoading, type) = stateAndType, type == .music else {
|
||||
// Player closed or switched to non-music — report and clear
|
||||
self.reportAndReset()
|
||||
return
|
||||
}
|
||||
|
||||
guard case let .state(state) = stateOrLoading else {
|
||||
return // loading state, ignore
|
||||
}
|
||||
|
||||
let itemStableId = state.item.stableId
|
||||
|
||||
// Detect track switch
|
||||
if let currentId = self.currentItemStableId, currentId != itemStableId {
|
||||
self.reportAndReset()
|
||||
}
|
||||
|
||||
// Start new session if needed
|
||||
if self.currentItemStableId == nil {
|
||||
self.startSession(item: state.item, status: state.status)
|
||||
}
|
||||
|
||||
// Update current item reference
|
||||
self.currentItemStableId = itemStableId
|
||||
|
||||
// Process playback status
|
||||
self.processStatus(state.status)
|
||||
}
|
||||
|
||||
/// Called when musicMediaPlayer is set to nil (player closed).
|
||||
func playerClosed() {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
self.reportAndReset()
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
private func startSession(item: SharedMediaPlaylistItem, status: MediaPlayerStatus) {
|
||||
// Extract FileMediaReference from the playlist item
|
||||
guard let playbackData = item.playbackData,
|
||||
case let .telegramFile(fileReference, _, _) = playbackData.source else {
|
||||
return
|
||||
}
|
||||
|
||||
self.currentFileReference = fileReference
|
||||
self.trackDuration = status.duration
|
||||
self.accumulatedDuration = 0
|
||||
self.lastPosition = status.timestamp
|
||||
self.lastGenerationTimestamp = status.generationTimestamp
|
||||
self.lastBaseRate = status.baseRate
|
||||
self.lastSeekId = status.seekId
|
||||
self.isPlaying = false
|
||||
self.pauseTimer?.invalidate()
|
||||
self.pauseTimer = nil
|
||||
}
|
||||
|
||||
private func processStatus(_ status: MediaPlayerStatus) {
|
||||
let wasPlaying = self.isPlaying
|
||||
let nowPlaying: Bool
|
||||
|
||||
switch status.status {
|
||||
case .playing:
|
||||
nowPlaying = true
|
||||
case let .buffering(_, whilePlaying, _, _):
|
||||
nowPlaying = whilePlaying
|
||||
case .paused:
|
||||
nowPlaying = false
|
||||
}
|
||||
|
||||
// Accumulate duration if we were playing (and no seek occurred)
|
||||
let seekOccurred = status.seekId != self.lastSeekId
|
||||
if wasPlaying && !seekOccurred {
|
||||
let positionDelta = status.timestamp - self.lastPosition
|
||||
let wallDelta = status.generationTimestamp - self.lastGenerationTimestamp
|
||||
let maxExpected = wallDelta * max(self.lastBaseRate, 0.5) * 1.5
|
||||
|
||||
if positionDelta > 0 && (wallDelta <= 0 || positionDelta <= maxExpected) {
|
||||
self.accumulatedDuration += positionDelta
|
||||
}
|
||||
}
|
||||
|
||||
self.lastPosition = status.timestamp
|
||||
self.lastGenerationTimestamp = status.generationTimestamp
|
||||
self.lastBaseRate = status.baseRate
|
||||
self.lastSeekId = status.seekId
|
||||
self.trackDuration = status.duration
|
||||
|
||||
// Handle play/pause transitions
|
||||
if nowPlaying && !wasPlaying {
|
||||
// Resumed playing
|
||||
self.pauseTimer?.invalidate()
|
||||
self.pauseTimer = nil
|
||||
} else if !nowPlaying && wasPlaying {
|
||||
// Just paused — start pause timer
|
||||
self.startPauseTimer()
|
||||
}
|
||||
|
||||
self.isPlaying = nowPlaying
|
||||
}
|
||||
|
||||
// MARK: - Pause Timer
|
||||
|
||||
private func startPauseTimer() {
|
||||
self.pauseTimer?.invalidate()
|
||||
let timer = SwiftSignalKit.Timer(timeout: MusicListenTracker.pauseTimeoutSeconds, repeat: false, completion: { [weak self] in
|
||||
self?.pauseTimerFired()
|
||||
}, queue: Queue.mainQueue())
|
||||
self.pauseTimer = timer
|
||||
timer.start()
|
||||
}
|
||||
|
||||
private func pauseTimerFired() {
|
||||
// Paused > 60s — report current session
|
||||
self.reportAndReset()
|
||||
}
|
||||
|
||||
// MARK: - Reporting
|
||||
|
||||
private func reportAndReset() {
|
||||
self.pauseTimer?.invalidate()
|
||||
self.pauseTimer = nil
|
||||
|
||||
guard let fileReference = self.currentFileReference else {
|
||||
self.resetSession()
|
||||
return
|
||||
}
|
||||
|
||||
let duration = self.accumulatedDuration
|
||||
let trackDuration = self.trackDuration
|
||||
|
||||
// Check minimum threshold: min(30s, 90% of track)
|
||||
let threshold = min(30.0, trackDuration * 0.9)
|
||||
if duration >= threshold && duration > 0 && trackDuration > 0 {
|
||||
let reportedDuration = Int(duration)
|
||||
self.reportDisposable.set(
|
||||
self.engine.messages.reportMusicListened(file: fileReference, duration: reportedDuration).startStrict()
|
||||
)
|
||||
}
|
||||
|
||||
self.resetSession()
|
||||
}
|
||||
|
||||
private func resetSession() {
|
||||
self.currentFileReference = nil
|
||||
self.currentItemStableId = nil
|
||||
self.trackDuration = 0
|
||||
self.accumulatedDuration = 0
|
||||
self.lastPosition = 0
|
||||
self.lastGenerationTimestamp = 0
|
||||
self.lastBaseRate = 1.0
|
||||
self.lastSeekId = 0
|
||||
self.isPlaying = false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue