zkit-ui 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +56 -0
  2. package/android/build.gradle +100 -0
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/cn/fontree/zkit/ui/preview/GaleriaModule.kt +43 -0
  5. package/android/src/main/java/cn/fontree/zkit/ui/preview/GaleriaView.kt +305 -0
  6. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryLayoutSupport.java +450 -0
  7. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryMediaItem.java +36 -0
  8. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryPagerAdapter.java +140 -0
  9. package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySharedElementGeometry.java +25 -0
  10. package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySharedElementNames.java +39 -0
  11. package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySharedElementState.java +25 -0
  12. package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySourceViewRegistry.java +115 -0
  13. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryTransitionSnapshotView.java +60 -0
  14. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryVideoPlayerView.java +338 -0
  15. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryViewerOverlayView.java +636 -0
  16. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryViewerPageView.java +357 -0
  17. package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryViewerTransitionController.java +223 -0
  18. package/android/src/main/res/layout/galeria_player_view.xml +9 -0
  19. package/dist/all.d.ts +2 -0
  20. package/dist/all.d.ts.map +1 -1
  21. package/dist/all.js +24 -22
  22. package/dist/ui/NativeImagePreview/NativeImagePreview.d.ts +3 -0
  23. package/dist/ui/NativeImagePreview/NativeImagePreview.d.ts.map +1 -0
  24. package/dist/ui/NativeImagePreview/NativeImagePreview.js +99 -0
  25. package/dist/ui/NativeImagePreview/NativeImagePreview.web.d.ts +3 -0
  26. package/dist/ui/NativeImagePreview/NativeImagePreview.web.d.ts.map +1 -0
  27. package/dist/ui/NativeImagePreview/NativeImagePreview.web.js +56 -0
  28. package/dist/ui/NativeImagePreview/context.d.ts +14 -0
  29. package/dist/ui/NativeImagePreview/context.d.ts.map +1 -0
  30. package/dist/ui/NativeImagePreview/context.js +38 -0
  31. package/dist/ui/NativeImagePreview/index.d.ts +3 -0
  32. package/dist/ui/NativeImagePreview/index.d.ts.map +1 -0
  33. package/dist/ui/NativeImagePreview/index.js +5 -0
  34. package/dist/ui/NativeImagePreview/types.d.ts +66 -0
  35. package/dist/ui/NativeImagePreview/types.d.ts.map +1 -0
  36. package/dist/ui/NativeImagePreview/types.js +2 -0
  37. package/dist/ui/NativeImagePreview/utils.d.ts +8 -0
  38. package/dist/ui/NativeImagePreview/utils.d.ts.map +1 -0
  39. package/dist/ui/NativeImagePreview/utils.js +82 -0
  40. package/expo-module.config.json +11 -0
  41. package/ios/GaleriaModule.swift +46 -0
  42. package/ios/GaleriaView.swift +253 -0
  43. package/ios/GalleryViewer/GaleriaImageLoader.swift +73 -0
  44. package/ios/GalleryViewer/GaleriaImageViewInteraction.swift +245 -0
  45. package/ios/GalleryViewer/GaleriaLayoutSupport.swift +143 -0
  46. package/ios/GalleryViewer/GaleriaMedia.swift +28 -0
  47. package/ios/GalleryViewer/GaleriaSourceViewRegistry.swift +80 -0
  48. package/ios/GalleryViewer/GaleriaVideoPlayerView.swift +437 -0
  49. package/ios/GalleryViewer/GaleriaViewerConfiguration.swift +51 -0
  50. package/ios/GalleryViewer/GaleriaViewerController.swift +745 -0
  51. package/ios/GalleryViewer/GaleriaViewerPageView.swift +623 -0
  52. package/ios/GalleryViewer/GaleriaViewerTransitionCoordinator.swift +312 -0
  53. package/package.json +20 -12
  54. package/src/all.ts +18 -0
  55. package/src/services/ImagePreviewService/README.md +19 -0
  56. package/src/ui/NativeImagePreview/NativeImagePreview.tsx +146 -0
  57. package/src/ui/NativeImagePreview/NativeImagePreview.web.tsx +37 -0
  58. package/src/ui/NativeImagePreview/README.md +111 -0
  59. package/src/ui/NativeImagePreview/context.tsx +22 -0
  60. package/src/ui/NativeImagePreview/index.tsx +18 -0
  61. package/src/ui/NativeImagePreview/types.ts +92 -0
  62. package/src/ui/NativeImagePreview/utils.ts +107 -0
  63. package/zkit-ui.podspec +13 -1
@@ -0,0 +1,143 @@
1
+ import UIKit
2
+
3
+ struct GaleriaSharedElementGeometry {
4
+ let visibleFrameInWindow: CGRect
5
+ let contentFrameInVisibleBounds: CGRect
6
+ let cornerRadius: CGFloat
7
+ }
8
+
9
+ struct GaleriaSharedElementState {
10
+ let image: UIImage
11
+ let geometry: GaleriaSharedElementGeometry
12
+ }
13
+
14
+ extension CGRect {
15
+ func galeriaPixelAligned(scale: CGFloat) -> CGRect {
16
+ guard scale > 0 else { return self }
17
+ return CGRect(
18
+ x: (origin.x * scale).rounded() / scale,
19
+ y: (origin.y * scale).rounded() / scale,
20
+ width: (size.width * scale).rounded() / scale,
21
+ height: (size.height * scale).rounded() / scale
22
+ )
23
+ }
24
+
25
+ func galeriaApproximatelyEquals(_ other: CGRect, tolerance: CGFloat = 0.5) -> Bool {
26
+ abs(minX - other.minX) <= tolerance
27
+ && abs(minY - other.minY) <= tolerance
28
+ && abs(width - other.width) <= tolerance
29
+ && abs(height - other.height) <= tolerance
30
+ }
31
+ }
32
+
33
+ extension GaleriaSharedElementGeometry {
34
+ func galeriaPixelAligned(scale: CGFloat) -> GaleriaSharedElementGeometry {
35
+ GaleriaSharedElementGeometry(
36
+ visibleFrameInWindow: visibleFrameInWindow.galeriaPixelAligned(scale: scale),
37
+ contentFrameInVisibleBounds: contentFrameInVisibleBounds.galeriaPixelAligned(scale: scale),
38
+ cornerRadius: (cornerRadius * scale).rounded() / scale
39
+ )
40
+ }
41
+
42
+ func approximatelyEquals(_ other: GaleriaSharedElementGeometry, tolerance: CGFloat = 0.5) -> Bool {
43
+ visibleFrameInWindow.galeriaApproximatelyEquals(other.visibleFrameInWindow, tolerance: tolerance)
44
+ && contentFrameInVisibleBounds.galeriaApproximatelyEquals(
45
+ other.contentFrameInVisibleBounds,
46
+ tolerance: tolerance
47
+ )
48
+ && abs(cornerRadius - other.cornerRadius) <= tolerance
49
+ }
50
+ }
51
+
52
+ extension GaleriaSharedElementState {
53
+ func approximatelyEquals(_ other: GaleriaSharedElementState, tolerance: CGFloat = 0.5) -> Bool {
54
+ geometry.approximatelyEquals(other.geometry, tolerance: tolerance)
55
+ && abs(image.size.width - other.image.size.width) <= tolerance
56
+ && abs(image.size.height - other.image.size.height) <= tolerance
57
+ }
58
+ }
59
+
60
+ extension UIView {
61
+ func pinToSuperviewEdges(
62
+ top: CGFloat = 0,
63
+ leading: CGFloat = 0,
64
+ trailing: CGFloat = 0,
65
+ bottom: CGFloat = 0
66
+ ) {
67
+ guard let superview = superview else { return }
68
+ translatesAutoresizingMaskIntoConstraints = false
69
+ NSLayoutConstraint.activate([
70
+ topAnchor.constraint(equalTo: superview.topAnchor, constant: top),
71
+ leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: leading),
72
+ trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: -trailing),
73
+ bottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: -bottom),
74
+ ])
75
+ }
76
+
77
+ func pinToSuperviewEdges(inset: CGFloat) {
78
+ pinToSuperviewEdges(top: inset, leading: inset, trailing: inset, bottom: inset)
79
+ }
80
+
81
+ func frameInWindow() -> CGRect {
82
+ convert(bounds, to: nil)
83
+ }
84
+ }
85
+
86
+ extension UIImageView {
87
+ func imageContentFrameInWindow() -> CGRect {
88
+ guard let image = image else { return frameInWindow() }
89
+ guard image.size.width > 0, image.size.height > 0 else { return frameInWindow() }
90
+ guard bounds.width > 0, bounds.height > 0 else { return frameInWindow() }
91
+
92
+ let widthRatio = bounds.width / image.size.width
93
+ let heightRatio = bounds.height / image.size.height
94
+
95
+ let scale: CGFloat
96
+ switch contentMode {
97
+ case .scaleAspectFill:
98
+ scale = max(widthRatio, heightRatio)
99
+ default:
100
+ scale = min(widthRatio, heightRatio)
101
+ }
102
+
103
+ let contentSize = CGSize(
104
+ width: image.size.width * scale,
105
+ height: image.size.height * scale
106
+ )
107
+ let contentFrame = CGRect(
108
+ x: (bounds.width - contentSize.width) * 0.5,
109
+ y: (bounds.height - contentSize.height) * 0.5,
110
+ width: contentSize.width,
111
+ height: contentSize.height
112
+ )
113
+ return convert(contentFrame, to: nil)
114
+ }
115
+
116
+ func galeriaSharedElementState(clippingFrameInWindow: CGRect? = nil) -> GaleriaSharedElementState? {
117
+ guard let image = image else { return nil }
118
+ guard image.size.width > 0, image.size.height > 0 else { return nil }
119
+
120
+ let clippingFrame = clippingFrameInWindow ?? frameInWindow()
121
+ guard clippingFrame.width > 0, clippingFrame.height > 0 else { return nil }
122
+
123
+ let contentFrame = imageContentFrameInWindow()
124
+ var visibleFrame = clippingFrame.intersection(contentFrame)
125
+ if visibleFrame.isNull || visibleFrame.isEmpty {
126
+ visibleFrame = clippingFrame
127
+ }
128
+
129
+ let geometry = GaleriaSharedElementGeometry(
130
+ visibleFrameInWindow: visibleFrame,
131
+ contentFrameInVisibleBounds: contentFrame.offsetBy(
132
+ dx: -visibleFrame.minX,
133
+ dy: -visibleFrame.minY
134
+ ),
135
+ cornerRadius: (clipsToBounds || layer.masksToBounds) ? layer.cornerRadius : 0
136
+ )
137
+ let scale = window?.screen.scale ?? UIScreen.main.scale
138
+ return GaleriaSharedElementState(
139
+ image: image,
140
+ geometry: geometry.galeriaPixelAligned(scale: scale)
141
+ )
142
+ }
143
+ }
@@ -0,0 +1,28 @@
1
+ import UIKit
2
+
3
+ public enum GaleriaMediaItem {
4
+ case image(UIImage?)
5
+ case imageURL(URL, placeholder: UIImage?)
6
+ case video(url: URL, poster: URL?)
7
+ }
8
+
9
+ public protocol GaleriaDataSource: AnyObject {
10
+ func numberOfItems() -> Int
11
+ func item(at index: Int) -> GaleriaMediaItem
12
+ }
13
+
14
+ public final class GaleriaArrayDataSource: GaleriaDataSource {
15
+ private let items: [GaleriaMediaItem]
16
+
17
+ public init(items: [GaleriaMediaItem]) {
18
+ self.items = items
19
+ }
20
+
21
+ public func numberOfItems() -> Int {
22
+ items.count
23
+ }
24
+
25
+ public func item(at index: Int) -> GaleriaMediaItem {
26
+ items[index]
27
+ }
28
+ }
@@ -0,0 +1,80 @@
1
+ import UIKit
2
+
3
+ final class GaleriaSourceViewRegistry {
4
+ static let shared = GaleriaSourceViewRegistry()
5
+
6
+ private final class WeakImageView {
7
+ weak var value: UIImageView?
8
+
9
+ init(_ value: UIImageView) {
10
+ self.value = value
11
+ }
12
+ }
13
+
14
+ private var anchors: [String: [Int: WeakImageView]] = [:]
15
+
16
+ private init() {}
17
+
18
+ func register(_ imageView: UIImageView, galleryId: String, index: Int) {
19
+ cleanup()
20
+ var galleryAnchors = anchors[galleryId] ?? [:]
21
+ galleryAnchors[index] = WeakImageView(imageView)
22
+ anchors[galleryId] = galleryAnchors
23
+ }
24
+
25
+ func unregister(_ imageView: UIImageView, galleryId: String?, index: Int?) {
26
+ cleanup()
27
+ if let galleryId = galleryId {
28
+ remove(imageView, from: galleryId, index: index)
29
+ } else {
30
+ for galleryId in Array(anchors.keys) {
31
+ remove(imageView, from: galleryId, index: nil)
32
+ }
33
+ }
34
+ cleanup()
35
+ }
36
+
37
+ func sourceView(for galleryId: String, index: Int) -> UIImageView? {
38
+ cleanup()
39
+ guard
40
+ let imageView = anchors[galleryId]?[index]?.value,
41
+ let window = imageView.window,
42
+ !imageView.isHidden
43
+ else {
44
+ return nil
45
+ }
46
+
47
+ let frameInWindow = imageView.frameInWindow()
48
+ guard !frameInWindow.isEmpty, frameInWindow.intersects(window.bounds) else {
49
+ return nil
50
+ }
51
+
52
+ return imageView
53
+ }
54
+
55
+ private func remove(_ imageView: UIImageView, from galleryId: String, index: Int?) {
56
+ guard var galleryAnchors = anchors[galleryId] else { return }
57
+
58
+ if let index = index {
59
+ if let registeredView = galleryAnchors[index]?.value, registeredView === imageView {
60
+ galleryAnchors.removeValue(forKey: index)
61
+ }
62
+ } else {
63
+ galleryAnchors = galleryAnchors.filter { _, weakImageView in
64
+ guard let registeredView = weakImageView.value else { return false }
65
+ return registeredView !== imageView
66
+ }
67
+ }
68
+
69
+ anchors[galleryId] = galleryAnchors.isEmpty ? nil : galleryAnchors
70
+ }
71
+
72
+ private func cleanup() {
73
+ anchors = anchors.compactMapValues { galleryAnchors in
74
+ let activeAnchors = galleryAnchors.filter { _, weakImageView in
75
+ weakImageView.value != nil
76
+ }
77
+ return activeAnchors.isEmpty ? nil : activeAnchors
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,437 @@
1
+ import AVFoundation
2
+ import UIKit
3
+
4
+ final class GaleriaVideoPlayerView: UIView {
5
+ private var player: AVPlayer?
6
+ private var playerLayer: AVPlayerLayer?
7
+ private var timeObserver: Any?
8
+ private var endObserver: NSObjectProtocol?
9
+ private var readyForDisplayObservation: NSKeyValueObservation?
10
+ private var itemStatusObservation: NSKeyValueObservation?
11
+ private var likelyToKeepUpObservation: NSKeyValueObservation?
12
+ private var bufferFullObservation: NSKeyValueObservation?
13
+ private var playbackGeneration = 0
14
+ private var isScrubbing = false
15
+ private var wasPlayingBeforeScrub = false
16
+ private var hasReportedFirstFrame = false
17
+ private var isPrimingFirstFrame = false
18
+ private var controlsVisible = false
19
+ private var controlsContainerConstraints: [NSLayoutConstraint] = []
20
+
21
+ private let playButton = UIButton(type: .system)
22
+ private let slider = UISlider()
23
+ private let timeLabel = UILabel()
24
+ private let controlsContainer = UIView()
25
+
26
+ var onControlsInteractStart: (() -> Void)?
27
+ var onControlsInteractEnd: (() -> Void)?
28
+ var onFirstFrameReady: (() -> Void)?
29
+
30
+ var url: URL? {
31
+ didSet {
32
+ setupPlayer()
33
+ }
34
+ }
35
+
36
+ override init(frame: CGRect) {
37
+ super.init(frame: frame)
38
+ setupUI()
39
+ }
40
+
41
+ required init?(coder: NSCoder) {
42
+ fatalError("init(coder:) has not been implemented")
43
+ }
44
+
45
+ override func layoutSubviews() {
46
+ super.layoutSubviews()
47
+ playerLayer?.frame = bounds
48
+ }
49
+
50
+ deinit {
51
+ tearDownPlayer()
52
+ }
53
+
54
+ func toggleControls() {
55
+ setControlsVisible(!controlsVisible, animated: true)
56
+ }
57
+
58
+ func resetToStart() {
59
+ player?.pause()
60
+ player?.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
61
+ slider.value = 0
62
+ updateTimeLabel(current: 0, duration: CMTimeGetSeconds(player?.currentItem?.duration ?? .zero))
63
+ updatePlayButton(isPlaying: false)
64
+ }
65
+
66
+ func play() {
67
+ if hasReportedFirstFrame == false {
68
+ primeFirstFrameIfNeeded()
69
+ }
70
+ player?.isMuted = false
71
+ player?.play()
72
+ updatePlayButton(isPlaying: true)
73
+ }
74
+
75
+ func pause() {
76
+ player?.pause()
77
+ if hasReportedFirstFrame == false {
78
+ isPrimingFirstFrame = false
79
+ }
80
+ updatePlayButton(isPlaying: false)
81
+ }
82
+
83
+ func setPlayerVisible(_ visible: Bool) {
84
+ CATransaction.begin()
85
+ CATransaction.setDisableActions(true)
86
+ playerLayer?.frame = bounds
87
+ playerLayer?.isHidden = !visible
88
+ playerLayer?.opacity = visible ? 1 : 0
89
+ CATransaction.commit()
90
+ if visible {
91
+ setNeedsLayout()
92
+ layoutIfNeeded()
93
+ }
94
+ }
95
+
96
+ func attachControls(to hostView: UIView) {
97
+ if controlsContainer.superview === hostView {
98
+ hostView.bringSubviewToFront(controlsContainer)
99
+ return
100
+ }
101
+
102
+ NSLayoutConstraint.deactivate(controlsContainerConstraints)
103
+ controlsContainer.removeFromSuperview()
104
+ hostView.addSubview(controlsContainer)
105
+ controlsContainer.translatesAutoresizingMaskIntoConstraints = false
106
+ controlsContainerConstraints = [
107
+ controlsContainer.bottomAnchor.constraint(equalTo: hostView.safeAreaLayoutGuide.bottomAnchor, constant: -20),
108
+ controlsContainer.leadingAnchor.constraint(equalTo: hostView.leadingAnchor, constant: 12),
109
+ controlsContainer.trailingAnchor.constraint(equalTo: hostView.trailingAnchor, constant: -12),
110
+ controlsContainer.heightAnchor.constraint(equalToConstant: 56),
111
+ ]
112
+ NSLayoutConstraint.activate(controlsContainerConstraints)
113
+ hostView.bringSubviewToFront(controlsContainer)
114
+ }
115
+
116
+ func setControlsVisible(_ visible: Bool, animated: Bool) {
117
+ controlsVisible = visible
118
+ let animations = {
119
+ self.controlsContainer.alpha = visible ? 1 : 0
120
+ }
121
+ let completion: (Bool) -> Void = { _ in
122
+ self.controlsContainer.isUserInteractionEnabled = visible
123
+ }
124
+
125
+ if animated {
126
+ UIView.animate(
127
+ withDuration: 0.22,
128
+ delay: 0,
129
+ options: [.curveEaseInOut, .beginFromCurrentState],
130
+ animations: animations,
131
+ completion: completion
132
+ )
133
+ } else {
134
+ animations()
135
+ completion(true)
136
+ }
137
+ }
138
+
139
+ func isTouchOnInteractiveControls(atWindowPoint point: CGPoint) -> Bool {
140
+ return expandedFrameForInteraction(of: controlsContainer, padding: 20)?.contains(point) == true
141
+ }
142
+
143
+ func isPointInsideInteractiveControls(_ point: CGPoint) -> Bool {
144
+ guard controlsVisible else { return false }
145
+ guard let hostView = controlsContainer.superview else { return false }
146
+ let pointInHost = hostView.convert(point, from: self)
147
+ if controlsContainer.frame.insetBy(dx: -20, dy: -20).contains(pointInHost) {
148
+ return true
149
+ }
150
+ return false
151
+ }
152
+
153
+ func primeFirstFrameIfNeeded() {
154
+ guard let player = player else { return }
155
+ guard hasReportedFirstFrame == false, isPrimingFirstFrame == false else { return }
156
+ isPrimingFirstFrame = true
157
+ player.isMuted = true
158
+ player.play()
159
+ }
160
+
161
+ func resetForReuse() {
162
+ setControlsVisible(false, animated: false)
163
+ url = nil
164
+ }
165
+
166
+ private func setupUI() {
167
+ backgroundColor = .clear
168
+
169
+ controlsContainer.backgroundColor = UIColor.black.withAlphaComponent(0.5)
170
+ controlsContainer.layer.cornerRadius = 12
171
+ controlsContainer.translatesAutoresizingMaskIntoConstraints = false
172
+ controlsContainer.alpha = 0
173
+
174
+ playButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
175
+ playButton.setPreferredSymbolConfiguration(
176
+ UIImage.SymbolConfiguration(pointSize: 20, weight: .bold),
177
+ forImageIn: .normal
178
+ )
179
+ playButton.tintColor = .white
180
+ playButton.translatesAutoresizingMaskIntoConstraints = false
181
+ playButton.addTarget(self, action: #selector(handlePlayButtonTouchDown), for: .touchDown)
182
+ playButton.addTarget(self, action: #selector(togglePlay), for: .touchUpInside)
183
+ playButton.addTarget(
184
+ self,
185
+ action: #selector(handleControlInteractionEnded),
186
+ for: [.touchUpInside, .touchUpOutside, .touchCancel]
187
+ )
188
+ controlsContainer.addSubview(playButton)
189
+
190
+ slider.minimumValue = 0
191
+ slider.maximumValue = 1
192
+ slider.value = 0
193
+ slider.tintColor = .white
194
+ slider.translatesAutoresizingMaskIntoConstraints = false
195
+ slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
196
+ slider.addTarget(self, action: #selector(handleSliderTouchDown), for: .touchDown)
197
+ slider.addTarget(self, action: #selector(handleSliderTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
198
+ controlsContainer.addSubview(slider)
199
+
200
+ timeLabel.textColor = .white
201
+ timeLabel.font = .systemFont(ofSize: 13, weight: .regular)
202
+ timeLabel.text = "00:00 / 00:00"
203
+ timeLabel.translatesAutoresizingMaskIntoConstraints = false
204
+ controlsContainer.addSubview(timeLabel)
205
+
206
+ NSLayoutConstraint.activate([
207
+ playButton.leadingAnchor.constraint(equalTo: controlsContainer.leadingAnchor, constant: 10),
208
+ playButton.centerYAnchor.constraint(equalTo: controlsContainer.centerYAnchor),
209
+ playButton.widthAnchor.constraint(equalToConstant: 44),
210
+ playButton.heightAnchor.constraint(equalToConstant: 44),
211
+
212
+ slider.leadingAnchor.constraint(equalTo: playButton.trailingAnchor, constant: 10),
213
+ slider.trailingAnchor.constraint(equalTo: timeLabel.leadingAnchor, constant: -10),
214
+ slider.centerYAnchor.constraint(equalTo: controlsContainer.centerYAnchor),
215
+
216
+ timeLabel.trailingAnchor.constraint(equalTo: controlsContainer.trailingAnchor, constant: -12),
217
+ timeLabel.centerYAnchor.constraint(equalTo: controlsContainer.centerYAnchor),
218
+ ])
219
+
220
+ attachControls(to: self)
221
+ }
222
+
223
+ private func setupPlayer() {
224
+ playbackGeneration += 1
225
+ let generation = playbackGeneration
226
+ tearDownPlayer()
227
+ guard let url = url else { return }
228
+
229
+ let item = AVPlayerItem(url: url)
230
+ player = AVPlayer(playerItem: item)
231
+ playerLayer = AVPlayerLayer(player: player)
232
+ playerLayer?.videoGravity = .resizeAspect
233
+ if let playerLayer = playerLayer {
234
+ layer.insertSublayer(playerLayer, at: 0)
235
+ }
236
+ playerLayer?.isHidden = true
237
+ player?.pause()
238
+ player?.isMuted = true
239
+ player?.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
240
+ hasReportedFirstFrame = false
241
+ isPrimingFirstFrame = false
242
+ slider.value = 0
243
+ updateTimeLabel(current: 0, duration: 0)
244
+ updatePlayButton(isPlaying: false)
245
+
246
+ let notifyFirstFrameReadyIfPossible: () -> Void = { [weak self] in
247
+ guard let self = self, self.playbackGeneration == generation, self.hasReportedFirstFrame == false else { return }
248
+ guard let currentItem = self.player?.currentItem else { return }
249
+ let isReadyToPlay = currentItem.status == .readyToPlay
250
+ let isLikelyToKeepUp = currentItem.isPlaybackLikelyToKeepUp || currentItem.isPlaybackBufferFull
251
+ let isReadyForDisplay = self.playerLayer?.isReadyForDisplay == true
252
+ guard isReadyToPlay, isLikelyToKeepUp, isReadyForDisplay else { return }
253
+ self.hasReportedFirstFrame = true
254
+ self.isPrimingFirstFrame = false
255
+ self.player?.pause()
256
+ self.player?.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
257
+ self.player?.isMuted = false
258
+ DispatchQueue.main.async {
259
+ guard self.playbackGeneration == generation else { return }
260
+ DispatchQueue.main.async {
261
+ guard self.playbackGeneration == generation else { return }
262
+ self.onFirstFrameReady?()
263
+ }
264
+ }
265
+ }
266
+
267
+ itemStatusObservation = item.observe(\.status, options: [.new, .initial]) { _, _ in
268
+ notifyFirstFrameReadyIfPossible()
269
+ }
270
+ likelyToKeepUpObservation = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new, .initial]) { _, _ in
271
+ notifyFirstFrameReadyIfPossible()
272
+ }
273
+ bufferFullObservation = item.observe(\.isPlaybackBufferFull, options: [.new, .initial]) { _, _ in
274
+ notifyFirstFrameReadyIfPossible()
275
+ }
276
+
277
+ readyForDisplayObservation = playerLayer?.observe(\.isReadyForDisplay, options: [.new, .initial]) { [weak self] _, change in
278
+ guard let self = self, self.playbackGeneration == generation, change.newValue == true else {
279
+ return
280
+ }
281
+ notifyFirstFrameReadyIfPossible()
282
+ }
283
+
284
+ timeObserver = player?.addPeriodicTimeObserver(
285
+ forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
286
+ queue: .main
287
+ ) { [weak self] time in
288
+ guard let self = self, self.playbackGeneration == generation, let item = self.player?.currentItem else { return }
289
+ let duration = CMTimeGetSeconds(item.duration)
290
+ let current = CMTimeGetSeconds(time)
291
+ if duration.isFinite && duration > 0, !self.isScrubbing {
292
+ self.slider.value = Float(current / duration)
293
+ }
294
+ self.updateTimeLabel(current: current, duration: duration)
295
+ }
296
+
297
+ endObserver = NotificationCenter.default.addObserver(
298
+ forName: .AVPlayerItemDidPlayToEndTime,
299
+ object: player?.currentItem,
300
+ queue: .main
301
+ ) { [weak self] _ in
302
+ guard let self = self, self.playbackGeneration == generation else { return }
303
+ self.resetToStart()
304
+ }
305
+ }
306
+
307
+ @objc
308
+ private func togglePlay() {
309
+ if hasReportedFirstFrame == false {
310
+ primeFirstFrameIfNeeded()
311
+ }
312
+ guard let player = player else { return }
313
+ if player.timeControlStatus == .playing {
314
+ player.pause()
315
+ updatePlayButton(isPlaying: false)
316
+ } else {
317
+ player.isMuted = false
318
+ player.play()
319
+ updatePlayButton(isPlaying: true)
320
+ }
321
+ }
322
+
323
+ @objc
324
+ private func sliderValueChanged() {
325
+ guard let player = player, let item = player.currentItem else { return }
326
+ let duration = CMTimeGetSeconds(item.duration)
327
+ guard duration.isFinite && duration > 0 else { return }
328
+ let targetTime = Double(slider.value) * duration
329
+ isScrubbing = true
330
+ player.seek(
331
+ to: CMTime(seconds: targetTime, preferredTimescale: 600),
332
+ toleranceBefore: .zero,
333
+ toleranceAfter: .zero
334
+ )
335
+ updateTimeLabel(current: targetTime, duration: duration)
336
+ }
337
+
338
+ private func formatTime(_ seconds: Double) -> String {
339
+ guard seconds.isFinite && seconds >= 0 else { return "--:--" }
340
+ let total = Int(seconds.rounded())
341
+ return String(format: "%02d:%02d", total / 60, total % 60)
342
+ }
343
+
344
+ private func updateTimeLabel(current: Double, duration: Double) {
345
+ timeLabel.text = "\(formatTime(current)) / \(formatTime(duration))"
346
+ }
347
+
348
+ private func updatePlayButton(isPlaying: Bool) {
349
+ playButton.setImage(UIImage(systemName: isPlaying ? "pause.fill" : "play.fill"), for: .normal)
350
+ }
351
+
352
+ private func expandedFrameForInteraction(of view: UIView, padding: CGFloat) -> CGRect? {
353
+ guard controlsVisible else { return nil }
354
+ guard view.alpha > 0.01, !view.bounds.isEmpty else { return nil }
355
+ return view.convert(view.bounds.insetBy(dx: -padding, dy: -padding), to: nil)
356
+ }
357
+
358
+ @objc
359
+ private func handleSliderTouchDown() {
360
+ guard let player = player else { return }
361
+ isScrubbing = true
362
+ wasPlayingBeforeScrub = player.timeControlStatus == .playing
363
+ player.pause()
364
+ onControlsInteractStart?()
365
+ }
366
+
367
+ @objc
368
+ private func handleSliderTouchUp() {
369
+ guard let player = player, let item = player.currentItem else {
370
+ isScrubbing = false
371
+ onControlsInteractEnd?()
372
+ return
373
+ }
374
+ let duration = CMTimeGetSeconds(item.duration)
375
+ guard duration.isFinite && duration > 0 else {
376
+ isScrubbing = false
377
+ onControlsInteractEnd?()
378
+ return
379
+ }
380
+ let targetTime = Double(slider.value) * duration
381
+ player.seek(
382
+ to: CMTime(seconds: targetTime, preferredTimescale: 600),
383
+ toleranceBefore: .zero,
384
+ toleranceAfter: .zero
385
+ ) { [weak self] _ in
386
+ DispatchQueue.main.async {
387
+ guard let self = self else { return }
388
+ self.isScrubbing = false
389
+ if self.wasPlayingBeforeScrub {
390
+ self.play()
391
+ } else {
392
+ self.pause()
393
+ }
394
+ self.onControlsInteractEnd?()
395
+ }
396
+ }
397
+ }
398
+
399
+ @objc
400
+ private func handlePlayButtonTouchDown() {
401
+ onControlsInteractStart?()
402
+ }
403
+
404
+ @objc
405
+ private func handleControlInteractionEnded() {
406
+ onControlsInteractEnd?()
407
+ }
408
+
409
+ private func tearDownPlayer() {
410
+ if let timeObserver = timeObserver, let player = player {
411
+ player.removeTimeObserver(timeObserver)
412
+ }
413
+ if let endObserver = endObserver {
414
+ NotificationCenter.default.removeObserver(endObserver)
415
+ }
416
+ readyForDisplayObservation?.invalidate()
417
+ itemStatusObservation?.invalidate()
418
+ likelyToKeepUpObservation?.invalidate()
419
+ bufferFullObservation?.invalidate()
420
+ readyForDisplayObservation = nil
421
+ itemStatusObservation = nil
422
+ likelyToKeepUpObservation = nil
423
+ bufferFullObservation = nil
424
+ timeObserver = nil
425
+ endObserver = nil
426
+
427
+ player?.pause()
428
+ player = nil
429
+ playerLayer?.removeFromSuperlayer()
430
+ playerLayer = nil
431
+
432
+ isScrubbing = false
433
+ wasPlayingBeforeScrub = false
434
+ hasReportedFirstFrame = false
435
+ isPrimingFirstFrame = false
436
+ }
437
+ }