Skip to content

Commit f8903cf

Browse files
committed
Keep the viewer's own chrome visible through QuickLook's churn
QuickLook swaps its navigation item on every refresh that re-installs its list button, and the sender and timestamp header was only installed from viewWillLayoutSubviews, so the item's filename showed until the next layout pass. The header is now reapplied from the same KVO and timer that swap the button back. A video's caption sits over the scrubber, and the tap to dismiss it went to QuickLook, hiding all the chrome and pausing playback with it. A viewer-level recognizer that every other single-tap recognizer defers to claims the touch, but only while a caption is on show and never over the navigation bar, the caption's own links or visible player controls. Any page change brings the caption back. The header reads "Loading..." over a thumbnail placeholder, and appends the attachment's place in its gallery. A video's poster gets the timeline's play badge as a vector overlay, crisp whatever the thumbnail's resolution rather than baked into the poster. QuickLook's text selection and caret take the view's tint, and the window's primary-text tint reads as a faint grey on dark media, so the composer's is used instead.
1 parent 5f011d0 commit f8903cf

2 files changed

Lines changed: 151 additions & 9 deletions

File tree

ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,16 @@ enum TimelineMediaPreviewItem: Equatable {
394394
}
395395
}
396396

397+
/// For a gallery attachment, its 1-based position within the gallery and the gallery's size.
398+
var galleryPosition: (index: Int, count: Int)? {
399+
guard case .galleryItem(let parent, let item) = content,
400+
let gallery = parent as? GalleryRoomTimelineItem,
401+
let index = gallery.content.items.firstIndex(where: { $0.id == item.id }) else {
402+
return nil
403+
}
404+
return (index + 1, gallery.content.items.count)
405+
}
406+
397407
var fileHandle: MediaFileHandleProxy? {
398408
didSet { updatePreviewItemValues() }
399409
}

ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Compound
1212
import QuickLook
1313
import 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

Comments
 (0)