Files
buzz/mobile/ios/Runner/InlinePhotoPicker.swift
klopez4212 ef57fec4d2 Fix gallery video selection, thumbnails, and swipe dismissal (#9356)
## Summary

Photos currently filters out videos, video messages without poster
metadata stay grey until playback, and the native gallery rejects
downward dismissal on video pages.

- Include photos and videos in the native inline/popover pickers, system
picker, and recent-media grid. Mixed selections retain their order and
videos use the video upload path. Remove the redundant Video menu row on
iOS and Android, whose Photos picker accepts both media types; reduce
their menu height to four rows. Native pickers request the current asset
representation to avoid compatibility transcoding and export up to three
files concurrently, preserving order and cleaning up cancelled/failed
batches.
- Show local stills for queued videos. For older messages without
posters, serialize thumbnail generation for the four rendered stack
cards, bound/cancel downloads, remove temporary videos, and save stills
in the existing account/community cache. Existing poster metadata is
used directly. The native viewer uses the poster while loading instead
of a play-button snapshot.
- Allow downward dismissal on native video pages while preserving
horizontal paging/scrubbing, zoomed-photo panning, and the accessible
Close button. Fire selection-click haptics as a swipe begins rather than
after the page settles, with no duplicate on completion or cancellation.
Page-indicator changes retain immediate feedback. Replace visible
video-loading text with the existing white arc-spinner treatment,
implemented natively with reduced-motion and lifecycle handling.
Completed swipes release the previous video player even after UIKit
removes it from the active children.

Legacy videos need one initial download (up to the existing 100 MB
limit) to generate their cached preview. This does not start playback.

### Related issue

Follow-up to merged #9259 and #9314. Searched open and closed
gallery/video issues and PRs; no duplicate fix found.

### Testing

- Full `just mobile-check` and `just mobile-test` passed at
`12e6ed6ae3`: 4,688 Flutter tests (71 skipped), 4 unconfigured-push
tests, and 27 crypto-package tests. File-size checks passed. Regressions
cover mixed image/video upload routing, queued still previews, the
rendered-card poster window, authenticated/cancelled downloads,
caching/cleanup, and native poster loading.
- Native gallery simulator regression fixture passed: photo/video
dismissal guards, real MP4 decoding, paging, selected-only playback
loading, and teardown.
- Swift type-check of the native picker and its dependencies passed,
with existing deprecation/capture warnings.
- `just ci` passed its repository-wide lint/type checks. The unrelated
Rust test rebuild was stopped after consuming about 13 GB to preserve
remaining disk space; only that run's generated caches were removed.

Release build at `fb09f9cb8` installed in place and launched on the
physical iPhone Air as **Buzz Photo Stack**, with
`BUZZ_NATIVE_PHOTO_ZOOM=true`. Existing app updated in place with the
same bundle and Keychain identity; no uninstall or reset. Owner tested
the physical-device preview and approved readiness for review. No
before/after capture was produced. The simulator evidence above is an
automated native fixture, not a full Buzz device journey.

Native preparation follow-up:
`scripts/test-mobile-picker-preparation.sh` passed on an iPhone Air
simulator, covering bounded overlap, out-of-order completion,
cancellation, and failure cleanup. The updated physical Air preview
launched successfully with unchanged preferences metadata. Cloud-only
assets may still require preparation.

Loader/haptics follow-up native fixture passed: real video readiness
hides the loader, error/retry and detach stop/restart it correctly,
swipe start triggers one haptic before selection changes, and
completion/cancellation/duplicate-start callbacks add none. Updated
release installed and launched on the physical Air; owner approved the
final preview for review.

Review: focused code review and native regression checks completed;
physical Air preview tested by the owner, who approved readiness.


P2 follow-up: video presentation uses an already decoded poster or the
source snapshot immediately, then forwards a pending poster
asynchronously. Late poster updates are limited to the selected native
page and do not restart playback. Added pending-provider presentation
coverage and native late/stale-poster checks. This follow-up has not
been installed on the physical preview yet.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-11 13:22:25 -08:00

283 lines
8.5 KiB
Swift

import Flutter
import PhotosUI
import UIKit
import UniformTypeIdentifiers
final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
private weak var parentViewController: UIViewController?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
self.messenger = messenger
self.parentViewController = parentViewController
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
InlinePhotoPickerPlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
parentViewController: parentViewController
)
}
}
final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
private let containerView: UIView
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private var pickerViewController: PHPickerViewController?
private var selectionGeneration = 0
private var selectionTask: Task<Void, Never>?
private var selectedTemporaryPaths: [String] = []
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
containerView = UIView(frame: frame)
channel = FlutterMethodChannel(
name: "buzz/inline_photo_picker/\(viewId)",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "claimSelection" else {
result(FlutterMethodNotImplemented)
return
}
let paths = call.arguments as? [String] ?? []
guard let self, paths == self.selectedTemporaryPaths else {
result(false)
return
}
self.selectedTemporaryPaths = []
result(true)
}
containerView.backgroundColor = .clear
containerView.clipsToBounds = true
if #available(iOS 17.0, *) {
installPicker()
}
}
deinit {
selectionTask?.cancel()
Self.removeTemporaryFiles(selectedTemporaryPaths)
channel.setMethodCallHandler(nil)
pickerViewController?.willMove(toParent: nil)
pickerViewController?.view.removeFromSuperview()
pickerViewController?.removeFromParent()
}
func view() -> UIView {
containerView
}
@available(iOS 17.0, *)
private func installPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .any(of: [.images, .videos])
configuration.selectionLimit = 0
configuration.selection = .continuousAndOrdered
configuration.preferredAssetRepresentationMode = .current
configuration.disabledCapabilities = [
.search,
.stagingArea,
.collectionNavigation,
.selectionActions,
]
configuration.edgesWithoutContentMargins = .all
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.backgroundColor = .clear
picker.view.translatesAutoresizingMaskIntoConstraints = false
if let parentViewController {
parentViewController.addChild(picker)
}
containerView.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
picker.view.topAnchor.constraint(equalTo: containerView.topAnchor),
picker.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
if parentViewController != nil {
picker.didMove(toParent: parentViewController)
}
pickerViewController = picker
containerView.layoutIfNeeded()
}
private func exportPickerResult(_ result: PHPickerResult) async throws -> String {
let provider = result.itemProvider
guard
let typeIdentifier = provider.registeredTypeIdentifiers.first(where: {
guard let type = UTType($0) else { return false }
return type.conforms(to: .image) || type.conforms(to: .movie)
})
else {
throw InlinePhotoPickerError.unsupportedImage
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) {
sourceURL,
error in
if let error {
continuation.resume(throwing: error)
return
}
guard let sourceURL else {
continuation.resume(throwing: InlinePhotoPickerError.missingFile)
return
}
do {
let fileExtension =
sourceURL.pathExtension.isEmpty
? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg")
: sourceURL.pathExtension
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.copyItem(
at: sourceURL,
to: destinationURL
)
continuation.resume(returning: destinationURL.path)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func removeTemporaryFiles(_ paths: [String]) {
for path in paths where !path.isEmpty {
try? FileManager.default.removeItem(atPath: path)
}
}
}
extension InlinePhotoPickerPlatformView: PHPickerViewControllerDelegate {
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
selectionGeneration += 1
let generation = selectionGeneration
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedTemporaryPaths)
selectedTemporaryPaths = []
channel.invokeMethod(
"selectionCountChanged",
arguments: results.count
)
guard !results.isEmpty else {
channel.invokeMethod("selectionDidChange", arguments: [String]())
return
}
selectionTask = Task { [weak self] in
guard let self else { return }
var paths: [String] = []
do {
paths = try await PickerFilePreparation.prepare(count: results.count) {
try await self.exportPickerResult(results[$0])
}
try Task.checkCancellation()
await MainActor.run {
guard generation == self.selectionGeneration else {
Self.removeTemporaryFiles(paths)
return
}
self.selectedTemporaryPaths = paths
self.selectionTask = nil
self.channel.invokeMethod("selectionDidChange", arguments: paths)
}
} catch is CancellationError {
Self.removeTemporaryFiles(paths)
} catch {
Self.removeTemporaryFiles(paths)
await MainActor.run {
guard generation == self.selectionGeneration else { return }
self.selectionTask = nil
self.channel.invokeMethod(
"didFail",
arguments: "Unable to prepare the selected photos."
)
}
}
}
}
}
private enum InlinePhotoPickerError: Error {
case missingFile
case unsupportedImage
}
// Keep Photos exports bounded without serializing an entire multi-selection.
// Drain cancelled exports before cleaning up: providers can finish after cancel.
enum PickerFilePreparation {
@MainActor
static func prepare(
count: Int,
export: @escaping @MainActor (Int) async throws -> String
) async throws -> [String] {
var paths = Array<String?>(repeating: nil, count: count)
var failure: Error?
await withTaskGroup(of: (Int, Result<String, Error>).self) { group in
func enqueue(_ index: Int) {
group.addTask {
do {
try Task.checkCancellation()
return (index, .success(try await export(index)))
} catch {
return (index, .failure(error))
}
}
}
var next = min(3, count)
for index in 0..<next { enqueue(index) }
for await (index, result) in group {
switch result {
case .success(let path): paths[index] = path
case .failure(let error):
if failure == nil { failure = error }
group.cancelAll()
}
if failure == nil && !Task.isCancelled && next < count {
enqueue(next)
next += 1
}
}
}
if Task.isCancelled || failure != nil {
for path in paths.compactMap({ $0 }) {
try? FileManager.default.removeItem(atPath: path)
}
throw failure ?? CancellationError()
}
return paths.compactMap { $0 }
}
}