WinterGram/submodules/InstantPageUI/Sources/InstantPageMediaOpen.swift
isaac 41aef1c928 InstantPage: exclude audio/music from the rich-content gallery
openInstantPageMedia builds the gallery from allMedias() filtered to
.image / .file. An audio track is an EngineMedia.file (a TelegramMediaFile
with an .Audio attribute), so it passed the .file case and appeared as a
gallery entry. Audio is enrolled in allMedias() only so the audio player
can gather sibling tracks for its playlist (handleOpenAudioTap); it is not
visual gallery content.

Split the .file case to keep videos/gifs/documents but drop music and
voice (!file.isMusic && !file.isVoice). No-op for V1 full-page Instant
View (its mediasFromItems never enrolled audio); fixes the V2 rich bubble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:49:24 +02:00

130 lines
7 KiB
Swift

import Foundation
import Display
import Postbox
import SwiftSignalKit
import AccountContext
import TelegramCore
import TelegramUIPreferences
import GalleryUI
import LocationUI
/// Routes a media tap (originating from V1's full-page Instant View, or from V2's in-bubble preview)
/// to the appropriate viewer:
/// - `.geo(map)` pushes `LocationViewController` via `push`.
/// - `.webpage(webPage)` cover single-entry `InstantPageGalleryController`.
/// - `.file(file)` with `isAnimated` single-entry gallery in "playing video" mode.
/// - Default multi-entry gallery built from `allMedias` (filtered to `.image` and non-audio
/// `.file` audio/music siblings are excluded), centered on the tapped media; "playing
/// video" mode is on.
///
/// Behavior matches V1's `InstantPageControllerNode.openMedia(_:)` bit-for-bit.
///
/// - Parameters:
/// - allMedias: every laid-out media on the page, in laid-out order. Used to build sibling
/// entries when the gallery needs them. Callers may pass `[]` for paths that don't need
/// siblings (e.g. webpage-cover single-entry gallery), but it's safer to always pass the
/// full list the helper filters/uses it only on the default branch.
/// - transitionArgsForMedia: invoked by the gallery presentation to find the source rect for
/// the swipe-back animation; return `nil` if the source view is not on screen.
/// - hiddenMediaCallback: invoked while the gallery is foregrounded so callers can hide the
/// source so the gallery's transitioning image isn't double-visible.
public func openInstantPageMedia(
media: InstantPageMedia,
allMedias: [InstantPageMedia],
webPage: TelegramMediaWebpage,
context: AccountContext,
userLocation: MediaResourceUserLocation,
present: (ViewController, Any?) -> Void,
push: (ViewController) -> Void,
openUrl: @escaping (InstantPageUrlItem) -> Void,
baseNavigationController: () -> NavigationController?,
transitionArgsForMedia: @escaping (InstantPageMedia) -> GalleryTransitionArguments?,
hiddenMediaCallback: @escaping (InstantPageMedia?) -> Void
) {
if case let .geo(map) = media.media {
let controllerParams = LocationViewParams(sendLiveLocation: { _ in
}, stopLiveLocation: { _ in
}, openUrl: { _ in }, openPeer: { _ in
}, showAll: false)
let peer = TelegramUser(id: PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(0)), accessHash: nil, firstName: "", lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)
let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: peer.id, namespace: 0, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peer, text: "", attributes: [], media: [map], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:])
let controller = LocationViewController(context: context, subject: EngineMessage(message), params: controllerParams)
push(controller)
return
}
var fromPlayingVideo = false
var entries: [InstantPageGalleryEntry] = []
if case let .webpage(webPage) = media.media {
entries.append(InstantPageGalleryEntry(index: 0, pageId: webPage.webpageId, media: media, caption: nil, credit: nil, location: nil))
} else if case let .file(file) = media.media, file.isAnimated {
fromPlayingVideo = true
entries.append(InstantPageGalleryEntry(index: Int32(media.index), pageId: webPage.webpageId, media: media, caption: media.caption, credit: media.credit, location: nil))
} else {
fromPlayingVideo = true
let filteredMedias = allMedias.filter { item in
switch item.media {
case .image:
return true
case let .file(file):
// Audio/music files are enrolled in `allMedias()` only so the audio playlist can
// gather its siblings (see `handleOpenAudioTap`); they are not visual gallery
// content, so keep videos/gifs/documents but exclude music and voice.
return !file.isMusic && !file.isVoice
default:
return false
}
}
for media in filteredMedias {
entries.append(InstantPageGalleryEntry(index: Int32(media.index), pageId: webPage.webpageId, media: media, caption: media.caption, credit: media.credit, location: InstantPageGalleryEntryLocation(position: Int32(entries.count), totalCount: Int32(filteredMedias.count))))
}
}
var centralIndex: Int?
for i in 0 ..< entries.count {
if entries[i].media == media {
centralIndex = i
break
}
}
if let centralIndex = centralIndex {
let controller = InstantPageGalleryController(context: context, userLocation: userLocation, webPage: webPage, entries: entries, centralIndex: centralIndex, fromPlayingVideo: fromPlayingVideo, replaceRootController: { _, _ in
}, baseNavigationController: baseNavigationController())
let hiddenMediaDisposable = MetaDisposable()
hiddenMediaDisposable.set((controller.hiddenMedia |> deliverOnMainQueue).start(next: { entry in
hiddenMediaCallback(entry?.media)
}))
controller.openUrl = openUrl
// The disposable lives as long as the gallery controller. Bind its lifetime to the
// controller by attaching it as an associated object so it survives until dismissal.
InstantPageMediaOpenDisposableBox.attach(disposable: hiddenMediaDisposable, to: controller)
present(controller, InstantPageGalleryControllerPresentationArguments(transitionArguments: { entry -> GalleryTransitionArguments? in
return transitionArgsForMedia(entry.media)
}))
}
}
// MARK: - Disposable lifetime helper
/// Holds a `MetaDisposable` that subscribes to the gallery controller's `hiddenMedia` signal.
/// Without this, the disposable would deallocate after `openInstantPageMedia` returns and the
/// subscription would stop firing. We attach it to the gallery controller via objc associated
/// objects so it lives as long as the controller does.
private final class InstantPageMediaOpenDisposableBox {
private static var key: UInt8 = 0
let disposable: MetaDisposable
init(_ disposable: MetaDisposable) { self.disposable = disposable }
deinit { self.disposable.dispose() }
static func attach(disposable: MetaDisposable, to controller: ViewController) {
let box = InstantPageMediaOpenDisposableBox(disposable)
objc_setAssociatedObject(controller, &key, box, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}