react-native-photo-video-editor 0.1.0

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 (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,418 @@
1
+ import UIKit
2
+
3
+ /// Bottom sheet: search + category filter + a 4-column grid of OpenMoji stickers. Tapping one
4
+ /// downloads the full-res PNG, writes it to a temp file, and hands `(URL, UIImage)` back to
5
+ /// `onStickerPicked` — the caller feeds that straight into the existing
6
+ /// `handlePickedImage(_:image:purpose:)` insertion path (photo sticker chip / video sticker
7
+ /// action sheet), so this file has zero knowledge of `PhotoLayer`/session internals.
8
+ ///
9
+ /// Built entirely in code (no storyboard/xib), consistent with the rest of this codebase.
10
+ final class OnlineStickerSheetViewController: UIViewController {
11
+ /// Fired once, after the full-res download completes and the image is written to a temp file.
12
+ /// The sheet dismisses itself first, then invokes this closure.
13
+ var onStickerPicked: ((URL, UIImage) -> Void)?
14
+
15
+ private let catalog = OpenMojiCatalog.shared
16
+ private let loader = RemoteImageLoader.shared
17
+
18
+ private enum LoadState {
19
+ case loading, loaded, error(String)
20
+ }
21
+
22
+ private var loadState: LoadState = .loading
23
+ private var allEntries: [OpenMojiEntry] = []
24
+ private var displayedEntries: [OpenMojiEntry] = []
25
+ private var selectedGroup: String? // nil == "All"
26
+ private var searchQuery: String = ""
27
+ private var searchDebounceTimer: Timer?
28
+ private var downloadInFlightHexcode: String?
29
+
30
+ private let searchBar = UISearchBar()
31
+ private let categoryScroll = UIScrollView()
32
+ private let categoryStack = UIStackView()
33
+ private var categoryButtons: [String: UIButton] = [:] // key "" == All
34
+ private let collectionView: UICollectionView
35
+ private let spinner = UIActivityIndicatorView(style: .large)
36
+ private let emptyLabel = UILabel()
37
+ private let errorLabel = UILabel()
38
+ private let retryButton = UIButton(type: .system)
39
+ private let attributionLabel = UILabel()
40
+ private let stateContainer = UIView()
41
+
42
+ private static let cellReuseID = "OpenMojiCell"
43
+ private static let columns = 4
44
+ private static let cellSpacing: CGFloat = 8
45
+
46
+ init() {
47
+ let layout = UICollectionViewFlowLayout()
48
+ layout.minimumInteritemSpacing = Self.cellSpacing
49
+ layout.minimumLineSpacing = Self.cellSpacing
50
+ layout.sectionInset = UIEdgeInsets(top: Self.cellSpacing, left: Self.cellSpacing, bottom: Self.cellSpacing, right: Self.cellSpacing)
51
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
52
+ super.init(nibName: nil, bundle: nil)
53
+ }
54
+
55
+ required init?(coder: NSCoder) { nil }
56
+
57
+ override func viewDidLoad() {
58
+ super.viewDidLoad()
59
+ view.backgroundColor = DesignTokens.surfaceContainerLowest
60
+ buildUI()
61
+ loadCatalog()
62
+ }
63
+
64
+ // MARK: - UI construction
65
+
66
+ private func buildUI() {
67
+ searchBar.placeholder = "Search stickers"
68
+ searchBar.delegate = self
69
+ searchBar.searchBarStyle = .minimal
70
+ searchBar.backgroundColor = .clear
71
+
72
+ categoryScroll.showsHorizontalScrollIndicator = false
73
+ categoryStack.axis = .horizontal
74
+ categoryStack.spacing = 6
75
+ rebuildCategoryChips()
76
+ categoryScroll.addSubview(categoryStack)
77
+ categoryStack.translatesAutoresizingMaskIntoConstraints = false
78
+ NSLayoutConstraint.activate([
79
+ categoryStack.leadingAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.leadingAnchor, constant: 12),
80
+ categoryStack.trailingAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.trailingAnchor, constant: -12),
81
+ categoryStack.topAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.topAnchor),
82
+ categoryStack.bottomAnchor.constraint(equalTo: categoryScroll.contentLayoutGuide.bottomAnchor),
83
+ categoryStack.heightAnchor.constraint(equalTo: categoryScroll.frameLayoutGuide.heightAnchor),
84
+ ])
85
+
86
+ collectionView.backgroundColor = .clear
87
+ collectionView.dataSource = self
88
+ collectionView.delegate = self
89
+ collectionView.register(OpenMojiCell.self, forCellWithReuseIdentifier: Self.cellReuseID)
90
+
91
+ spinner.hidesWhenStopped = true
92
+
93
+ emptyLabel.text = "No stickers found."
94
+ emptyLabel.textColor = DesignTokens.onSurfaceVariant
95
+ emptyLabel.textAlignment = .center
96
+ emptyLabel.font = .systemFont(ofSize: 14)
97
+ emptyLabel.isHidden = true
98
+
99
+ errorLabel.text = "Couldn't load stickers — check your connection."
100
+ errorLabel.textColor = DesignTokens.onSurfaceVariant
101
+ errorLabel.textAlignment = .center
102
+ errorLabel.font = .systemFont(ofSize: 14)
103
+ errorLabel.numberOfLines = 0
104
+ errorLabel.isHidden = true
105
+
106
+ retryButton.setTitle("Retry", for: .normal)
107
+ retryButton.setTitleColor(DesignTokens.primaryContainer, for: .normal)
108
+ retryButton.isHidden = true
109
+ retryButton.addAction(UIAction { [weak self] _ in self?.loadCatalog(forceRefresh: true) }, for: .touchUpInside)
110
+
111
+ let stateStack = UIStackView(arrangedSubviews: [spinner, emptyLabel, errorLabel, retryButton])
112
+ stateStack.axis = .vertical
113
+ stateStack.alignment = .center
114
+ stateStack.spacing = 12
115
+ stateContainer.addSubview(stateStack)
116
+ stateStack.translatesAutoresizingMaskIntoConstraints = false
117
+ NSLayoutConstraint.activate([
118
+ stateStack.centerXAnchor.constraint(equalTo: stateContainer.centerXAnchor),
119
+ stateStack.centerYAnchor.constraint(equalTo: stateContainer.centerYAnchor),
120
+ stateStack.leadingAnchor.constraint(greaterThanOrEqualTo: stateContainer.leadingAnchor, constant: 24),
121
+ stateStack.trailingAnchor.constraint(lessThanOrEqualTo: stateContainer.trailingAnchor, constant: -24),
122
+ ])
123
+
124
+ attributionLabel.text = "Stickers by OpenMoji (CC BY-SA 4.0)"
125
+ attributionLabel.font = .systemFont(ofSize: 11)
126
+ attributionLabel.textColor = DesignTokens.outline
127
+ attributionLabel.textAlignment = .center
128
+
129
+ // stateContainer (spinner/empty/error) overlays the collection view's area rather than
130
+ // sharing stack space, since it's shown/hidden independently of the grid's own layout.
131
+ let root = UIStackView(arrangedSubviews: [searchBar, categoryScroll, collectionView, attributionLabel])
132
+ root.axis = .vertical
133
+ root.translatesAutoresizingMaskIntoConstraints = false
134
+
135
+ view.addSubview(root)
136
+ view.addSubview(stateContainer)
137
+ root.translatesAutoresizingMaskIntoConstraints = false
138
+ stateContainer.translatesAutoresizingMaskIntoConstraints = false
139
+ NSLayoutConstraint.activate([
140
+ root.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
141
+ root.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
142
+ root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
143
+ root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
144
+ categoryScroll.heightAnchor.constraint(equalToConstant: 40),
145
+ attributionLabel.heightAnchor.constraint(equalToConstant: 20),
146
+
147
+ stateContainer.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
148
+ stateContainer.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
149
+ stateContainer.topAnchor.constraint(equalTo: collectionView.topAnchor),
150
+ stateContainer.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
151
+ ])
152
+ }
153
+
154
+ private func rebuildCategoryChips() {
155
+ categoryStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
156
+ categoryButtons.removeAll()
157
+
158
+ let all = [("", "All")] + catalog.groups().map { ($0, Self.displayName(for: $0)) }
159
+ all.forEach { key, title in
160
+ let chip = makeChip(title: title) { [weak self] in self?.selectCategory(key) }
161
+ categoryButtons[key] = chip
162
+ categoryStack.addArrangedSubview(chip)
163
+ }
164
+ updateCategorySelectionUI()
165
+ }
166
+
167
+ private static func displayName(for group: String) -> String {
168
+ switch group {
169
+ case "smileys-emotion": return "Smileys"
170
+ case "people-body": return "People"
171
+ case "animals-nature": return "Animals"
172
+ case "food-drink": return "Food"
173
+ case "travel-places": return "Travel"
174
+ case "activities": return "Activities"
175
+ case "objects": return "Objects"
176
+ case "symbols": return "Symbols"
177
+ case "flags": return "Flags"
178
+ default: return group.capitalized
179
+ }
180
+ }
181
+
182
+ private func makeChip(title: String, handler: @escaping () -> Void) -> UIButton {
183
+ let chip = UIButton(type: .system)
184
+ chip.setTitle(title, for: .normal)
185
+ chip.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
186
+ chip.backgroundColor = DesignTokens.surfaceContainerHigh
187
+ chip.layer.cornerRadius = DesignTokens.radiusLg
188
+ chip.contentEdgeInsets = UIEdgeInsets(top: 0, left: DesignTokens.spaceMd, bottom: 0, right: DesignTokens.spaceMd)
189
+ chip.heightAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
190
+ chip.addAction(UIAction { _ in handler() }, for: .touchUpInside)
191
+ return chip
192
+ }
193
+
194
+ private func updateCategorySelectionUI() {
195
+ let activeKey = selectedGroup ?? ""
196
+ categoryButtons.forEach { key, chip in
197
+ let selected = key == activeKey
198
+ chip.setTitleColor(selected ? DesignTokens.onPrimaryContainer : DesignTokens.onSurfaceVariant, for: .normal)
199
+ chip.backgroundColor = selected ? DesignTokens.primaryContainer : DesignTokens.surfaceContainerHigh
200
+ }
201
+ }
202
+
203
+ // MARK: - Loading
204
+
205
+ private func loadCatalog(forceRefresh: Bool = false) {
206
+ setLoadState(.loading)
207
+ catalog.load { [weak self] entries, error in
208
+ guard let self else { return }
209
+ if let entries {
210
+ self.allEntries = entries
211
+ self.rebuildCategoryChips()
212
+ self.applyFilter()
213
+ self.setLoadState(.loaded)
214
+ } else {
215
+ self.setLoadState(.error((error as NSError?)?.localizedDescription ?? "Couldn't load stickers — check your connection."))
216
+ }
217
+ }
218
+ }
219
+
220
+ private func setLoadState(_ state: LoadState) {
221
+ loadState = state
222
+ switch state {
223
+ case .loading:
224
+ stateContainer.isHidden = false
225
+ spinner.startAnimating()
226
+ emptyLabel.isHidden = true
227
+ errorLabel.isHidden = true
228
+ retryButton.isHidden = true
229
+ case .loaded:
230
+ spinner.stopAnimating()
231
+ let isEmpty = displayedEntries.isEmpty
232
+ stateContainer.isHidden = !isEmpty
233
+ emptyLabel.isHidden = !isEmpty
234
+ errorLabel.isHidden = true
235
+ retryButton.isHidden = true
236
+ case .error(let message):
237
+ spinner.stopAnimating()
238
+ stateContainer.isHidden = false
239
+ emptyLabel.isHidden = true
240
+ errorLabel.isHidden = false
241
+ errorLabel.text = message
242
+ retryButton.isHidden = false
243
+ }
244
+ }
245
+
246
+ // MARK: - Filtering
247
+
248
+ private func selectCategory(_ group: String) {
249
+ selectedGroup = group.isEmpty ? nil : group
250
+ updateCategorySelectionUI()
251
+ applyFilter()
252
+ }
253
+
254
+ /// Search takes precedence over category when non-empty; otherwise filter by selected
255
+ /// category, with "All" (nil) meaning no filter.
256
+ private func applyFilter() {
257
+ if !searchQuery.isEmpty {
258
+ displayedEntries = catalog.search(searchQuery)
259
+ } else if let selectedGroup {
260
+ displayedEntries = catalog.byGroup(selectedGroup)
261
+ } else {
262
+ displayedEntries = allEntries
263
+ }
264
+ collectionView.reloadData()
265
+ if case .loaded = loadState { setLoadState(.loaded) }
266
+ }
267
+
268
+ // MARK: - Sticker selection / download
269
+
270
+ private func stickerTapped(_ entry: OpenMojiEntry, at indexPath: IndexPath) {
271
+ guard downloadInFlightHexcode == nil else { return }
272
+ downloadInFlightHexcode = entry.hexcode
273
+ if let cell = collectionView.cellForItem(at: indexPath) as? OpenMojiCell {
274
+ cell.setDownloading(true)
275
+ }
276
+ let url = catalog.fullURL(entry.hexcode)
277
+ loader.downloadFull(url: url) { [weak self] fileURL, error in
278
+ guard let self else { return }
279
+ self.downloadInFlightHexcode = nil
280
+ if let cell = self.collectionView.cellForItem(at: indexPath) as? OpenMojiCell {
281
+ cell.setDownloading(false)
282
+ }
283
+ guard let fileURL, let data = try? Data(contentsOf: fileURL), let image = UIImage(data: data) else {
284
+ self.showInlineDownloadError()
285
+ return
286
+ }
287
+ // Write to a fresh temp file (consistent with the "Upload" flow's `writeTempImage`) so the
288
+ // caller always receives a `file://` URL it can treat like any other picked image, decoupled
289
+ // from our own disk cache's lifetime.
290
+ guard let tempURL = self.writeTempImage(image) else {
291
+ self.showInlineDownloadError()
292
+ return
293
+ }
294
+ self.dismiss(animated: true) { [weak self] in
295
+ self?.onStickerPicked?(tempURL, image)
296
+ }
297
+ }
298
+ }
299
+
300
+ private func writeTempImage(_ image: UIImage) -> URL? {
301
+ guard let data = image.pngData() else { return nil }
302
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png")
303
+ do {
304
+ try data.write(to: url)
305
+ return url
306
+ } catch {
307
+ return nil
308
+ }
309
+ }
310
+
311
+ private func showInlineDownloadError() {
312
+ let alert = UIAlertController(title: "Download failed", message: "Couldn't download that sticker. Please try again.", preferredStyle: .alert)
313
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
314
+ present(alert, animated: true)
315
+ }
316
+ }
317
+
318
+ // MARK: - UISearchBarDelegate (debounced)
319
+
320
+ extension OnlineStickerSheetViewController: UISearchBarDelegate {
321
+ func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
322
+ searchDebounceTimer?.invalidate()
323
+ searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { [weak self] _ in
324
+ guard let self else { return }
325
+ self.searchQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
326
+ self.applyFilter()
327
+ }
328
+ }
329
+
330
+ func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
331
+ searchBar.resignFirstResponder()
332
+ }
333
+
334
+ func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
335
+ searchBar.text = ""
336
+ searchBar.resignFirstResponder()
337
+ searchDebounceTimer?.invalidate()
338
+ searchQuery = ""
339
+ applyFilter()
340
+ }
341
+ }
342
+
343
+ // MARK: - UICollectionView
344
+
345
+ extension OnlineStickerSheetViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
346
+ func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
347
+ displayedEntries.count
348
+ }
349
+
350
+ func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
351
+ let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.cellReuseID, for: indexPath) as! OpenMojiCell
352
+ let entry = displayedEntries[indexPath.item]
353
+ cell.configure(url: catalog.thumbnailURL(entry.hexcode), loader: loader)
354
+ return cell
355
+ }
356
+
357
+ func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
358
+ collectionView.deselectItem(at: indexPath, animated: true)
359
+ guard indexPath.item < displayedEntries.count else { return }
360
+ stickerTapped(displayedEntries[indexPath.item], at: indexPath)
361
+ }
362
+
363
+ func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
364
+ let totalSpacing = Self.cellSpacing * CGFloat(Self.columns + 1)
365
+ let width = (collectionView.bounds.width - totalSpacing) / CGFloat(Self.columns)
366
+ return CGSize(width: max(width, 1), height: max(width, 1))
367
+ }
368
+ }
369
+
370
+ /// One grid cell: a thumbnail image view plus a small overlay spinner shown while its full-res
371
+ /// download is in flight.
372
+ private final class OpenMojiCell: UICollectionViewCell {
373
+ private let imageView = UIImageView()
374
+ private let spinner = UIActivityIndicatorView(style: .medium)
375
+
376
+ override init(frame: CGRect) {
377
+ super.init(frame: frame)
378
+ contentView.backgroundColor = DesignTokens.surfaceContainerHigh
379
+ contentView.layer.cornerRadius = DesignTokens.radiusMd
380
+ contentView.clipsToBounds = true
381
+
382
+ imageView.contentMode = .scaleAspectFit
383
+ contentView.addSubview(imageView)
384
+ imageView.translatesAutoresizingMaskIntoConstraints = false
385
+ NSLayoutConstraint.activate([
386
+ imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 4),
387
+ imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4),
388
+ imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
389
+ imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4),
390
+ ])
391
+
392
+ spinner.hidesWhenStopped = true
393
+ contentView.addSubview(spinner)
394
+ spinner.translatesAutoresizingMaskIntoConstraints = false
395
+ NSLayoutConstraint.activate([
396
+ spinner.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
397
+ spinner.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
398
+ ])
399
+ }
400
+
401
+ required init?(coder: NSCoder) { nil }
402
+
403
+ func configure(url: URL, loader: RemoteImageLoader) {
404
+ loader.loadThumbnail(url: url, into: imageView)
405
+ }
406
+
407
+ func setDownloading(_ downloading: Bool) {
408
+ if downloading { spinner.startAnimating() } else { spinner.stopAnimating() }
409
+ imageView.alpha = downloading ? 0.4 : 1.0
410
+ }
411
+
412
+ override func prepareForReuse() {
413
+ super.prepareForReuse()
414
+ imageView.image = nil
415
+ spinner.stopAnimating()
416
+ imageView.alpha = 1.0
417
+ }
418
+ }
@@ -0,0 +1,188 @@
1
+ import Foundation
2
+
3
+ /// A single entry from OpenMoji's `openmoji.json` index that we care about.
4
+ /// Mirrors Android's `OpenMojiCatalog.Entry` in spirit (same field set).
5
+ struct OpenMojiEntry {
6
+ let emoji: String
7
+ let hexcode: String
8
+ let group: String
9
+ let annotation: String
10
+ let tags: String
11
+ }
12
+
13
+ /// Fetches, disk-caches, and serves the OpenMoji sticker index
14
+ /// (`https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/data/openmoji.json`).
15
+ ///
16
+ /// No third-party networking/JSON dependency is used — this codebase is deliberately
17
+ /// dependency-light (see `stickerAssets: [[String: Any]]` in `PhotoVideoEditorViewController`
18
+ /// for the existing dictionary-based JSON precedent). Plain `URLSession` + `JSONSerialization`.
19
+ final class OpenMojiCatalog {
20
+ static let shared = OpenMojiCatalog()
21
+
22
+ /// Fixed, sensible display order for OpenMoji's `group` values.
23
+ static let orderedGroups: [String] = [
24
+ "smileys-emotion",
25
+ "people-body",
26
+ "animals-nature",
27
+ "food-drink",
28
+ "travel-places",
29
+ "activities",
30
+ "objects",
31
+ "symbols",
32
+ "flags",
33
+ ]
34
+
35
+ private static let indexURL = URL(string: "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/data/openmoji.json")!
36
+ private static let cacheFileName = "openmoji_index.json"
37
+ private static let cacheTimestampKey = "OpenMojiCatalog.cacheTimestamp"
38
+ private static let cacheMaxAgeSeconds: TimeInterval = 7 * 24 * 60 * 60 // 7 days
39
+
40
+ private let queue = DispatchQueue(label: "com.photovideoeditor.openmojicatalog", qos: .userInitiated)
41
+ private var entries: [OpenMojiEntry]?
42
+ private var isLoading = false
43
+ private var pendingCompletions: [([OpenMojiEntry]?, Error?) -> Void] = []
44
+
45
+ private init() {}
46
+
47
+ private var cacheFileURL: URL? {
48
+ FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
49
+ .appendingPathComponent(Self.cacheFileName)
50
+ }
51
+
52
+ enum CatalogError: Error {
53
+ case network(Error)
54
+ case badResponse
55
+ case emptyCache
56
+ }
57
+
58
+ /// Loads the catalog (cache-first, network fallback with cache-refresh) and calls `completion`
59
+ /// on the main thread. Safe to call multiple times concurrently; duplicate in-flight calls are coalesced.
60
+ func load(completion: @escaping ([OpenMojiEntry]?, Error?) -> Void) {
61
+ if let entries {
62
+ DispatchQueue.main.async { completion(entries, nil) }
63
+ return
64
+ }
65
+ queue.async { [weak self] in
66
+ guard let self else { return }
67
+ self.pendingCompletions.append(completion)
68
+ if self.isLoading { return }
69
+ self.isLoading = true
70
+ self.loadLocked()
71
+ }
72
+ }
73
+
74
+ /// Must be called on `queue`. Decides whether to use the on-disk cache or fetch fresh, then
75
+ /// fans results out to every completion queued while the load was in flight.
76
+ private func loadLocked() {
77
+ let cacheAge = self.cacheAgeSeconds()
78
+ if let cacheAge, cacheAge < Self.cacheMaxAgeSeconds, let cached = self.readCache() {
79
+ self.finishLoad(entries: cached, error: nil)
80
+ return
81
+ }
82
+ self.fetchFromNetwork { [weak self] data, error in
83
+ guard let self else { return }
84
+ self.queue.async {
85
+ if let data {
86
+ self.writeCache(data)
87
+ let parsed = self.parse(data)
88
+ self.finishLoad(entries: parsed, error: parsed.isEmpty ? CatalogError.badResponse : nil)
89
+ } else if let cached = self.readCache() {
90
+ // Network failed but we have a cache of any age — fall back to it.
91
+ self.finishLoad(entries: cached, error: nil)
92
+ } else {
93
+ self.finishLoad(entries: nil, error: error ?? CatalogError.emptyCache)
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ /// Must be called on `queue`.
100
+ private func finishLoad(entries: [OpenMojiEntry]?, error: Error?) {
101
+ if let entries { self.entries = entries }
102
+ let completions = self.pendingCompletions
103
+ self.pendingCompletions = []
104
+ self.isLoading = false
105
+ DispatchQueue.main.async {
106
+ completions.forEach { $0(entries, entries == nil ? error : nil) }
107
+ }
108
+ }
109
+
110
+ private func fetchFromNetwork(completion: @escaping (Data?, Error?) -> Void) {
111
+ let task = URLSession.shared.dataTask(with: Self.indexURL) { data, response, error in
112
+ if let error {
113
+ completion(nil, CatalogError.network(error))
114
+ return
115
+ }
116
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let data else {
117
+ completion(nil, CatalogError.badResponse)
118
+ return
119
+ }
120
+ completion(data, nil)
121
+ }
122
+ task.resume()
123
+ }
124
+
125
+ private func cacheAgeSeconds() -> TimeInterval? {
126
+ let timestamp = UserDefaults.standard.double(forKey: Self.cacheTimestampKey)
127
+ guard timestamp > 0 else { return nil }
128
+ return Date().timeIntervalSince1970 - timestamp
129
+ }
130
+
131
+ private func readCache() -> [OpenMojiEntry]? {
132
+ guard let url = cacheFileURL, let data = try? Data(contentsOf: url) else { return nil }
133
+ let parsed = parse(data)
134
+ return parsed.isEmpty ? nil : parsed
135
+ }
136
+
137
+ private func writeCache(_ data: Data) {
138
+ guard let url = cacheFileURL else { return }
139
+ do {
140
+ try data.write(to: url, options: .atomic)
141
+ UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.cacheTimestampKey)
142
+ } catch {
143
+ // Best-effort cache; ignore write failures.
144
+ }
145
+ }
146
+
147
+ private func parse(_ data: Data) -> [OpenMojiEntry] {
148
+ guard let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
149
+ return raw.compactMap { dict in
150
+ guard
151
+ let hexcode = dict["hexcode"] as? String, !hexcode.isEmpty,
152
+ let group = dict["group"] as? String
153
+ else { return nil }
154
+ let emoji = dict["emoji"] as? String ?? ""
155
+ let annotation = dict["annotation"] as? String ?? ""
156
+ let tags = dict["tags"] as? String ?? ""
157
+ return OpenMojiEntry(emoji: emoji, hexcode: hexcode, group: group, annotation: annotation, tags: tags)
158
+ }
159
+ }
160
+
161
+ // MARK: - Query surface
162
+
163
+ func groups() -> [String] {
164
+ guard let entries else { return [] }
165
+ let present = Set(entries.map { $0.group })
166
+ return Self.orderedGroups.filter { present.contains($0) }
167
+ }
168
+
169
+ func byGroup(_ group: String) -> [OpenMojiEntry] {
170
+ (entries ?? []).filter { $0.group == group }
171
+ }
172
+
173
+ func search(_ query: String) -> [OpenMojiEntry] {
174
+ let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
175
+ guard !needle.isEmpty else { return entries ?? [] }
176
+ return (entries ?? []).filter {
177
+ $0.annotation.lowercased().contains(needle) || $0.tags.lowercased().contains(needle)
178
+ }
179
+ }
180
+
181
+ func thumbnailURL(_ hexcode: String) -> URL {
182
+ URL(string: "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/color/72x72/\(hexcode).png")!
183
+ }
184
+
185
+ func fullURL(_ hexcode: String) -> URL {
186
+ URL(string: "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@master/color/618x618/\(hexcode).png")!
187
+ }
188
+ }