diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 952a1b004e..1455401761 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -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 { + 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 in + return .single(.boolFalse) + } + |> ignoreValues + } } } diff --git a/submodules/TelegramCore/Sources/Utils/MessageReadMetricsTracker.swift b/submodules/TelegramCore/Sources/Utils/MessageReadMetricsTracker.swift new file mode 100644 index 0000000000..422e73feb1 --- /dev/null +++ b/submodules/TelegramCore/Sources/Utils/MessageReadMetricsTracker.swift @@ -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() + public var completedMetrics: Signal { + 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() + } +} diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index e7361cbd53..a8af8cef1f 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -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() { diff --git a/submodules/TelegramUI/Sources/MediaManager.swift b/submodules/TelegramUI/Sources/MediaManager.swift index a15b8172b7..f838410fdf 100644 --- a/submodules/TelegramUI/Sources/MediaManager.swift +++ b/submodules/TelegramUI/Sources/MediaManager.swift @@ -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() diff --git a/submodules/TelegramUI/Sources/MusicListenTracker.swift b/submodules/TelegramUI/Sources/MusicListenTracker.swift new file mode 100644 index 0000000000..0b9d0de397 --- /dev/null +++ b/submodules/TelegramUI/Sources/MusicListenTracker.swift @@ -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 + } +}