zkit-ui 2.0.3 → 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.
- package/README.md +56 -0
- package/android/build.gradle +100 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GaleriaModule.kt +43 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GaleriaView.kt +305 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryLayoutSupport.java +450 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryMediaItem.java +36 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryPagerAdapter.java +140 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySharedElementGeometry.java +25 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySharedElementNames.java +39 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySharedElementState.java +25 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GallerySourceViewRegistry.java +115 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryTransitionSnapshotView.java +60 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryVideoPlayerView.java +338 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryViewerOverlayView.java +636 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryViewerPageView.java +357 -0
- package/android/src/main/java/cn/fontree/zkit/ui/preview/GalleryViewerTransitionController.java +223 -0
- package/android/src/main/res/layout/galeria_player_view.xml +9 -0
- package/dist/all.d.ts +2 -0
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js +24 -22
- package/dist/ui/NativeImagePreview/NativeImagePreview.d.ts +3 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.js +99 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.web.d.ts +3 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.web.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/NativeImagePreview.web.js +56 -0
- package/dist/ui/NativeImagePreview/context.d.ts +14 -0
- package/dist/ui/NativeImagePreview/context.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/context.js +38 -0
- package/dist/ui/NativeImagePreview/index.d.ts +3 -0
- package/dist/ui/NativeImagePreview/index.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/index.js +5 -0
- package/dist/ui/NativeImagePreview/types.d.ts +66 -0
- package/dist/ui/NativeImagePreview/types.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/types.js +2 -0
- package/dist/ui/NativeImagePreview/utils.d.ts +8 -0
- package/dist/ui/NativeImagePreview/utils.d.ts.map +1 -0
- package/dist/ui/NativeImagePreview/utils.js +82 -0
- package/expo-module.config.json +11 -0
- package/ios/GaleriaModule.swift +46 -0
- package/ios/GaleriaView.swift +253 -0
- package/ios/GalleryViewer/GaleriaImageLoader.swift +73 -0
- package/ios/GalleryViewer/GaleriaImageViewInteraction.swift +245 -0
- package/ios/GalleryViewer/GaleriaLayoutSupport.swift +143 -0
- package/ios/GalleryViewer/GaleriaMedia.swift +28 -0
- package/ios/GalleryViewer/GaleriaSourceViewRegistry.swift +80 -0
- package/ios/GalleryViewer/GaleriaVideoPlayerView.swift +437 -0
- package/ios/GalleryViewer/GaleriaViewerConfiguration.swift +51 -0
- package/ios/GalleryViewer/GaleriaViewerController.swift +745 -0
- package/ios/GalleryViewer/GaleriaViewerPageView.swift +623 -0
- package/ios/GalleryViewer/GaleriaViewerTransitionCoordinator.swift +312 -0
- package/package.json +21 -13
- package/src/all.ts +18 -0
- package/src/services/ImagePreviewService/README.md +19 -0
- package/src/ui/NativeImagePreview/NativeImagePreview.tsx +146 -0
- package/src/ui/NativeImagePreview/NativeImagePreview.web.tsx +37 -0
- package/src/ui/NativeImagePreview/README.md +111 -0
- package/src/ui/NativeImagePreview/context.tsx +22 -0
- package/src/ui/NativeImagePreview/index.tsx +18 -0
- package/src/ui/NativeImagePreview/types.ts +92 -0
- package/src/ui/NativeImagePreview/utils.ts +107 -0
- 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
|
+
}
|