@@ -12,7 +12,7 @@ import Compound
1212import QuickLook
1313import SwiftUI
1414
15- class TimelineMediaPreviewController : QLPreviewController {
15+ class TimelineMediaPreviewController : QLPreviewController , UIGestureRecognizerDelegate {
1616 private let context : TimelineMediaPreviewViewModel . Context
1717
1818 private let headerHostingController : UIHostingController < HeaderView >
@@ -22,6 +22,10 @@ class TimelineMediaPreviewController: QLPreviewController {
2222 private var detailsHostingController : UIHostingController < TimelineMediaPreviewDetailsView > ?
2323
2424 private var barButtonTimer : Timer ?
25+ /// The navigation item whose left button is being watched, and the watch itself.
26+ private var observedNavigationItem : UINavigationItem ?
27+ private var leftBarButtonObservation : NSKeyValueObservation ?
28+
2529 private var pageScrollViewObservation : AnyCancellable ?
2630 /// The content offset that the page scroll view rests at when showing the current item.
2731 private var pageScrollViewRestingOffset : CGFloat = 0
@@ -75,7 +79,6 @@ class TimelineMediaPreviewController: QLPreviewController {
7579
7680 view. addSubview ( captionView)
7781 // Constraints added later as the toolbar isn't available yet.
78-
7982 view. addSubview ( downloadIndicatorHostingController. view)
8083 downloadIndicatorHostingController. view. translatesAutoresizingMaskIntoConstraints = false
8184 NSLayoutConstraint . activate ( [
@@ -88,6 +91,7 @@ class TimelineMediaPreviewController: QLPreviewController {
8891 . sink { [ weak self] index in
8992 // This isn't removing duplicates which may try to download and/or write to disk concurrently????
9093 MXLog . info ( " Media viewer: index \( index) -> \( self ? . currentPreviewItemDescription ?? " nil " ) " )
94+ self ? . isCaptionSuppressed = false // Swiping away and back brings the caption back.
9195 self ? . loadCurrentItem ( )
9296 self ? . checkCurrentItemOnArrival ( )
9397 // A rebuild that found the pages moving re-arms for wherever the swipe lands.
@@ -120,6 +124,10 @@ class TimelineMediaPreviewController: QLPreviewController {
120124 }
121125 . store ( in: & cancellables)
122126
127+ // QuickLook's text selection (Live Text, documents) and caret take the view's tint: the
128+ // window's primary-text tint reads as a faint grey on dark media, so use the composer's.
129+ view. tintColor = . compound. iconAccentTertiary
130+
123131 dataSource = context. viewState. dataSource
124132 currentPreviewItemIndex = context. viewState. dataSource. initialItemIndex
125133 // The geometry QuickLook builds with, so the first count change already shifts the index
@@ -133,18 +141,90 @@ class TimelineMediaPreviewController: QLPreviewController {
133141 fatalError ( " init(coder:) has not been implemented " )
134142 }
135143
144+ // MARK: Caption dismissal
145+
146+ /// Set by a tap while the caption is on show: over a video the caption covers the
147+ /// scrubber, and QuickLook's own tap would hide the whole chrome (pausing playback)
148+ /// with it. The first tap hides just the caption instead; swiping away and back
149+ /// (or any page change) brings it back.
150+ private var isCaptionSuppressed = false
151+
152+ private lazy var captionDismissTap : UITapGestureRecognizer = {
153+ let tap = UITapGestureRecognizer ( target: self , action: #selector( handleCaptionDismissTap) )
154+ tap. delegate = self
155+ return tap
156+ } ( )
157+
158+ /// The recognizers already made to defer to the caption-dismiss tap.
159+ private let hookedTapRecognizers = NSHashTable< UITapGestureRecognizer> . weakObjects( )
160+
161+ /// Whether a tap right now should hide the caption rather than reach QuickLook.
162+ private var captionIsOnShow : Bool {
163+ !captionView. isHidden && !isCaptionSuppressed && context. viewState. currentItem. mediaItem? . hasCaption == true
164+ }
165+
166+ /// Makes every single-tap recognizer in the hierarchy (QuickLook's chrome toggle,
167+ /// AVKit's controls toggle) wait for the caption-dismiss tap, which only claims the
168+ /// touch while a caption is on show. Swept on the existing timer: pages (re)build
169+ /// their recognizers continuously.
170+ private func hookTapRecognizers( ) {
171+ var stack : [ UIView ] = [ view]
172+ while let subview = stack. popLast ( ) {
173+ for recognizer in subview. gestureRecognizers ?? [ ] {
174+ guard let tap = recognizer as? UITapGestureRecognizer ,
175+ tap !== captionDismissTap,
176+ tap. numberOfTapsRequired == 1 , tap. numberOfTouchesRequired == 1 ,
177+ !hookedTapRecognizers. contains ( tap) else { continue }
178+ tap. require ( toFail: captionDismissTap)
179+ hookedTapRecognizers. add ( tap)
180+ }
181+ stack. append ( contentsOf: subview. subviews)
182+ }
183+ }
184+
185+ @objc private func handleCaptionDismissTap( ) {
186+ MXLog . info ( " Media viewer: tap hides the caption " )
187+ isCaptionSuppressed = true
188+ captionView. isHidden = true
189+ }
190+
191+ func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer , shouldReceive touch: UITouch ) -> Bool {
192+ guard gestureRecognizer === captionDismissTap else { return true }
193+ guard captionIsOnShow else { return false }
194+ // Leave the chrome's controls, the caption itself (links) and any visible
195+ // player controls tappable.
196+ if let touchView = touch. view {
197+ if touchView is UIControl {
198+ return false
199+ }
200+ if let navigationBar, touchView. isDescendant ( of: navigationBar) {
201+ return false
202+ }
203+ if touchView. isDescendant ( of: captionView) {
204+ return false
205+ }
206+ if let bottomBarItemsContainer, touchView. isDescendant ( of: bottomBarItemsContainer) {
207+ return false
208+ }
209+ }
210+ return true
211+ }
212+
136213 // MARK: Layout
137214
138215 override func viewWillLayoutSubviews( ) {
139216 super. viewWillLayoutSubviews ( )
140217
141218 if let bottomBarItemsContainer {
219+ bottomBarItemsContainer. tintColor = . compound. iconPrimary // See `updateBarButtons`.
220+
142221 // Using the toolbar's visibility doesn't work so check its frame.
143- captionView . isHidden = if #available( iOS 26 , * ) {
222+ let chromeHidden = if #available( iOS 26 , * ) {
144223 navigationBar? . topItem? . leftBarButtonItem? . frame ( in: view) == nil
145224 } else {
146225 bottomBarItemsContainer. frame. minY >= view. frame. maxY
147226 }
227+ captionView. isHidden = chromeHidden || isCaptionSuppressed
148228
149229 if captionView. constraints. isEmpty {
150230 captionHostingController. view. translatesAutoresizingMaskIntoConstraints = false
@@ -165,6 +245,10 @@ class TimelineMediaPreviewController: QLPreviewController {
165245
166246 navigationBar? . topItem? . titleView = headerHostingController. view
167247
248+ if captionDismissTap. view == nil {
249+ view. addGestureRecognizer ( captionDismissTap)
250+ }
251+
168252 observePageScrollViewIfNeeded ( )
169253
170254 updateBarButtons ( )
@@ -177,6 +261,7 @@ class TimelineMediaPreviewController: QLPreviewController {
177261 self ? . updateBarButtons ( )
178262 // Also re-centers the overlay once the scroll view has settled on an item.
179263 self ? . updateOverlayPosition ( )
264+ self ? . hookTapRecognizers ( )
180265 }
181266 }
182267 }
@@ -190,9 +275,35 @@ class TimelineMediaPreviewController: QLPreviewController {
190275 private func updateBarButtons( ) {
191276 guard let topItem = navigationBar? . topItem else { return }
192277
278+ // The view's tint (for QuickLook's selection and caret) is inherited by every bar button
279+ // item, which would recolour the (i) and the controller's own buttons with it.
280+ navigationBar? . tintColor = . compound. iconPrimary
281+
282+ // React to the controller re-installing its list button as it happens (KVO fires
283+ // synchronously in the setter, before the frame renders) rather than on the timer's
284+ // next tick, which left the list button visible for up to 100ms on every item refresh
285+ // (a "pulse" of the (i) icon). The timer stays as a fallback.
286+ if observedNavigationItem !== topItem {
287+ observedNavigationItem = topItem
288+ leftBarButtonObservation = topItem. observe ( \. leftBarButtonItem) { [ weak self] _, _ in
289+ MainActor . assumeIsolated { self ? . updateBarButtons ( ) }
290+ }
291+ }
292+
293+ // The controller also swaps its navigation item (and with it our title view) on the same
294+ // refreshes, after which it shows the item's filename until the next layout pass; put the
295+ // sender/timestamp header back here, on the same trigger as the button.
296+ if topItem. titleView !== headerHostingController. view {
297+ topItem. titleView = headerHostingController. view
298+ }
299+
193300 if topItem. leftBarButtonItem? . customView == nil {
194301 let button = UIBarButtonItem ( customView: detailsButtonHostingController. view)
195- navigationBar? . topItem? . leftBarButtonItem = button
302+ // The controller re-installs its own button after every item refresh (e.g. once the
303+ // file has loaded), so this swap happens repeatedly; don't animate it back in each time.
304+ UIView . performWithoutAnimation {
305+ navigationBar? . topItem? . leftBarButtonItem = button
306+ }
196307 }
197308 }
198309
@@ -982,10 +1093,25 @@ private struct HeaderView: View {
9821093 context. viewState. currentItem
9831094 }
9841095
1096+ /// Whether the media is still on its way, as opposed to having failed or been held by a scan,
1097+ /// which the overlay reports for itself (the header would otherwise say "Loading…" over them).
1098+ private var isLoading : Bool {
1099+ guard case . media( let mediaItem) = currentItem else { return false }
1100+ return mediaItem. fileHandle == nil && mediaItem. downloadError == nil
1101+ }
1102+
1103+ /// The sender (or "Loading…" over a thumbnail placeholder), with the attachment's place in its
1104+ /// gallery appended, e.g. "Alice (2 of 3)", now that galleries are browsed inline with other media.
1105+ private func headerTitle( for mediaItem: TimelineMediaPreviewItem . Media ) -> String {
1106+ let title = isLoading ? L10n . commonLoading : mediaItem. sender. displayName ?? mediaItem. sender. id
1107+ guard let position = mediaItem. galleryPosition else { return title }
1108+ return " \( title) ( \( L10n . screenRoomPinnedBannerIndicator ( String ( position. index) , String ( position. count) ) ) ) "
1109+ }
1110+
9851111 var body : some View {
9861112 if let mediaItem = currentItem. mediaItem {
9871113 VStack ( spacing: 0 ) {
988- Text ( mediaItem . sender . displayName ?? mediaItem. sender . id )
1114+ Text ( headerTitle ( for : mediaItem) )
9891115 . font ( . compound. bodySMSemibold)
9901116 . foregroundStyle ( . compound. textPrimary)
9911117 Text ( mediaItem. timestamp. formatted ( date: . abbreviated, time: . omitted) )
@@ -1025,9 +1151,11 @@ private struct CaptionView: View {
10251151 }
10261152
10271153 var body : some View {
1028- if let mediaItem = currentItem. mediaItem, mediaItem. hasCaption {
1029- CaptionScrollView ( mediaItem: mediaItem)
1030- . transition ( . move( edge: . bottom) . combined ( with: . opacity) )
1154+ VStack ( spacing: 0 ) {
1155+ if let mediaItem = currentItem. mediaItem, mediaItem. hasCaption {
1156+ CaptionScrollView ( mediaItem: mediaItem)
1157+ . transition ( . move( edge: . bottom) . combined ( with: . opacity) )
1158+ }
10311159 }
10321160 }
10331161}
@@ -1090,8 +1218,12 @@ private struct DownloadIndicatorView: View {
10901218 case . media( let mediaItem) :
10911219 if mediaItem. downloadError != nil {
10921220 downloadErrorView
1093- } else if mediaItem. fileHandle == nil {
1221+ } else if mediaItem. fileHandle == nil , mediaItem . placeholderURL == nil {
10941222 loadingIndicator ( isScanning: false )
1223+ } else if mediaItem. fileHandle == nil , mediaItem. placeholderURL != nil , mediaItem. kind == . video {
1224+ // A video's poster gets the timeline's play badge, as a vector overlay (crisp whatever
1225+ // the thumbnail's resolution) rather than baked into the poster.
1226+ VideoPlayBadge ( )
10951227 }
10961228 case . contentScan( let scan) :
10971229 switch scan. state {
0 commit comments