diff --git a/WordPress/Classes/Services/MediaImageService.swift b/WordPress/Classes/Services/MediaImageService.swift index a308909d4d26..30707d97dcf6 100644 --- a/WordPress/Classes/Services/MediaImageService.swift +++ b/WordPress/Classes/Services/MediaImageService.swift @@ -64,7 +64,7 @@ final class MediaImageService: NSObject { return try? await Task.detached { let imageURL = try self.getCachedThumbnailURL(for: mediaID, size: size) let data = try Data(contentsOf: imageURL) - return try decompressedImage(from: data) + return try makeImage(from: data) }.value } @@ -116,7 +116,7 @@ final class MediaImageService: NSObject { let image = try? await Task.detached { let data = try Data(contentsOf: export.url) - return try decompressedImage(from: data) + return try makeImage(from: data) }.value // The order is important to ensure `export.url` still exists when creating an image @@ -143,15 +143,18 @@ final class MediaImageService: NSObject { guard !Task.isCancelled else { throw CancellationError() } - let (data, _) = try await session.data(for: request) - + let (data, response) = try await session.data(for: request) + guard let statusCode = (response as? HTTPURLResponse)?.statusCode, + (200..<400).contains(statusCode) else { + throw URLError(.unknown) + } + let image = try await Task.detached { + try makeImage(from: data) + }.value saveThumbnail(for: media.objectID, size: size) { targetURL in try data.write(to: targetURL) } - - return try await Task.detached { - try decompressedImage(from: data) - }.value + return image } // MARK: - Stubs @@ -254,7 +257,13 @@ private extension Media { } // Download a non-retina version for GIFs: makes a massive difference // in terms of size. Example: 2.4 MB -> 350 KB. - let targetSize = remoteURL.isGif ? targetSize.scaled(by: 1.0 / UIScreen.main.scale) : targetSize + let scale = UIScreen.main.scale + var targetSize = targetSize + if remoteURL.isGif { + targetSize = targetSize + .scaled(by: 1.0 / scale) + .scaled(by: min(2, scale)) + } if !isEligibleForPhoton { return WPImageURLHelper.imageURLWithSize(targetSize, forImageURL: remoteURL) } else { @@ -275,10 +284,13 @@ private extension Media { // Forces decompression (or bitmapping) to happen in the background. // It's very expensive for some image formats, such as JPEG. -private func decompressedImage(from data: Data) throws -> UIImage { +private func makeImage(from data: Data) throws -> UIImage { guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) } + if data.isMatchingMagicNumbers(Data.gifMagicNumbers) { + return AnimatedImageWrapper(gifData: data) ?? image + } guard isDecompressionNeeded(for: data) else { return image } @@ -299,6 +311,9 @@ private extension Data { // JPEG magic numbers https://en.wikipedia.org/wiki/JPEG static let jpegMagicNumbers: [UInt8] = [0xFF, 0xD8, 0xFF] + // GIF magic numbers https://en.wikipedia.org/wiki/GIF + static let gifMagicNumbers: [UInt8] = [0x47, 0x49, 0x46] + func isMatchingMagicNumbers(_ numbers: [UInt8?]) -> Bool { guard self.count >= numbers.count else { return false diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaCollectionViewController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaCollectionViewController.swift index 837c7d770db6..296012764848 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaCollectionViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaCollectionViewController.swift @@ -27,7 +27,7 @@ final class SiteMediaCollectionViewController: UIViewController, NSFetchedResult private var syncError: Error? private var pendingChanges: [(UICollectionView) -> Void] = [] private var selection = NSMutableOrderedSet() // `Media` - private var viewModels: [NSManagedObjectID: MediaCollectionCellViewModel] = [:] + private var viewModels: [NSManagedObjectID: SiteMediaCollectionCellViewModel] = [:] private let blog: Blog private let filter: Set? private let isShowingPendingUploads: Bool @@ -95,7 +95,7 @@ final class SiteMediaCollectionViewController: UIViewController, NSFetchedResult } private func configureCollectionView() { - collectionView.register(MediaCollectionCell.self, forCellWithReuseIdentifier: Constants.cellID) + collectionView.register(cell: SiteMediaCollectionCell.self) view.addSubview(collectionView) collectionView.translatesAutoresizingMaskIntoConstraints = false @@ -260,6 +260,13 @@ final class SiteMediaCollectionViewController: UIViewController, NSFetchedResult pendingChanges.append({ $0.deleteItems(at: [indexPath]) }) if let media = anObject as? Media { setSelect(false, for: media) + + if let viewController = navigationController?.topViewController, + viewController !== self, + let detailsViewController = viewController as? MediaItemViewController, + detailsViewController.media.objectID == media.objectID { + navigationController?.popViewController(animated: true) + } } else { assertionFailure("Invalid object: \(anObject)") } @@ -303,7 +310,7 @@ final class SiteMediaCollectionViewController: UIViewController, NSFetchedResult } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.cellID, for: indexPath) as! MediaCollectionCell + let cell = collectionView.dequeue(cell: SiteMediaCollectionCell.self, for: indexPath)! let media = fetchController.object(at: indexPath) let viewModel = getViewModel(for: media) cell.configure(viewModel: viewModel) @@ -392,11 +399,11 @@ final class SiteMediaCollectionViewController: UIViewController, NSFetchedResult // MARK: - Helpers // Create ViewModel lazily to avoid fetching more managed objects than needed. - private func getViewModel(for media: Media) -> MediaCollectionCellViewModel { + private func getViewModel(for media: Media) -> SiteMediaCollectionCellViewModel { if let viewModel = viewModels[media.objectID] { return viewModel } - let viewModel = MediaCollectionCellViewModel(media: media) + let viewModel = SiteMediaCollectionCellViewModel(media: media) viewModels[media.objectID] = viewModel return viewModel } @@ -452,10 +459,6 @@ extension SiteMediaCollectionViewController: NoResultsViewHost { } } -private enum Constants { - static let cellID = "cellID" -} - private enum Strings { static let syncFailed = NSLocalizedString("media.syncFailed", value: "Unable to sync media", comment: "Title of error prompt shown when a sync fails.") static let retryMenuRetry = NSLocalizedString("mediaLibrary.retryOptionsAlert.retry", value: "Retry Upload", comment: "User action to retry media upload.") diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCell.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCell.swift deleted file mode 100644 index d96a749a1769..000000000000 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCell.swift +++ /dev/null @@ -1,118 +0,0 @@ -import UIKit -import Combine - -final class MediaCollectionCell: UICollectionViewCell { - private let imageView = UIImageView() - private let overlayView = CircularProgressView() - private let placeholderView = UIView() - private var viewModel: MediaCollectionCellViewModel? - private var badgeView: MediaCollectionCellBadgeView? - private var cancellables: [AnyCancellable] = [] - - override init(frame: CGRect) { - super.init(frame: frame) - - placeholderView.backgroundColor = .secondarySystemBackground - - imageView.clipsToBounds = true - imageView.contentMode = .scaleAspectFill - imageView.accessibilityIgnoresInvertColors = true - - overlayView.backgroundColor = .neutral(.shade70).withAlphaComponent(0.5) - - contentView.addSubview(placeholderView) - placeholderView.translatesAutoresizingMaskIntoConstraints = false - contentView.pinSubviewToAllEdges(placeholderView) - - contentView.addSubview(imageView) - imageView.translatesAutoresizingMaskIntoConstraints = false - contentView.pinSubviewToAllEdges(imageView) - - contentView.addSubview(overlayView) - overlayView.translatesAutoresizingMaskIntoConstraints = false - contentView.pinSubviewToAllEdges(overlayView) - } - - required init?(coder: NSCoder) { - fatalError("Not implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - cancellables = [] - viewModel?.onDisappear() - viewModel = nil - - imageView.image = nil - imageView.alpha = 0 - placeholderView.alpha = 1 - badgeView?.isHidden = true - } - - func configure(viewModel: MediaCollectionCellViewModel) { - self.viewModel = viewModel - - if let image = viewModel.getCachedThubmnail() { - // Display with no animations. It should happen often thanks to prefetchig - imageView.image = image - imageView.alpha = 1 - placeholderView.alpha = 0 - } else { - let mediaID = viewModel.mediaID - viewModel.onImageLoaded = { [weak self] in - self?.didLoadImage($0, for: mediaID) - } - } - - viewModel.$overlayState.sink { [overlayView] in - if let state = $0 { - overlayView.state = state - overlayView.isHidden = false - } else { - overlayView.isHidden = true - } - }.store(in: &cancellables) - - viewModel.$badgeText.sink { [weak self] text in - guard let self else { return } - if let text { - let badgeView = self.getBadgeView() - badgeView.isHidden = false - badgeView.textLabel.text = text - } else { - self.badgeView?.isHidden = true - } - }.store(in: &cancellables) - - viewModel.onAppear() - } - - private func didLoadImage(_ image: UIImage, for mediaID: TaggedManagedObjectID) { - assert(Thread.isMainThread) - - guard viewModel?.mediaID == mediaID else { return } - - // TODO: Display an asset-specific placeholder on error - imageView.image = image - UIView.animate(withDuration: 0.15, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) { - self.imageView.alpha = 1 - self.placeholderView.alpha = 0 - } - } - - private func getBadgeView() -> MediaCollectionCellBadgeView { - if let badgeView { - return badgeView - } - let badgeView = MediaCollectionCellBadgeView() - contentView.addSubview(badgeView) - badgeView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - badgeView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4), - badgeView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4) - ]) - self.badgeView = badgeView - return badgeView - } -} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCellViewModel.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCellViewModel.swift deleted file mode 100644 index 91d8efaa0aa6..000000000000 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCellViewModel.swift +++ /dev/null @@ -1,139 +0,0 @@ -import UIKit - -final class MediaCollectionCellViewModel { - var onImageLoaded: ((UIImage) -> Void)? - @Published private(set) var overlayState: CircularProgressView.State? - @Published var badgeText: String? - let mediaID: TaggedManagedObjectID - var mediaType: MediaType - - private let media: Media - private let service: MediaImageService - private let cache: MemoryCache - private var isVisible = false - private var isPrefetchingNeeded = false - private var imageTask: Task? - private var statusObservation: NSKeyValueObservation? - private var thumbnailObservation: NSKeyValueObservation? - - deinit { - imageTask?.cancel() - } - - init(media: Media, - service: MediaImageService = .shared, - cache: MemoryCache = .shared) { - self.mediaID = TaggedManagedObjectID(media) - self.media = media - self.mediaType = media.mediaType - self.service = service - self.cache = cache - - statusObservation = media.observe(\.remoteStatusNumber, options: [.new]) { [weak self] media, _ in - self?.updateOverlayState() - } - - // No sure why but `.initial` didn't work. - self.updateOverlayState() - - thumbnailObservation = media.observe(\.localURL, options: [.new]) { [weak self] media, _ in - self?.didUpdateLocalThumbnail() - } - } - - // MARK: - View Lifecycle - - func onAppear() { - guard !isVisible else { return } - isVisible = true - fetchThumbnailIfNeeded() - } - - func onDisappear() { - guard isVisible else { return } - isVisible = false - cancelThumbnailRequestIfNeeded() - } - - func startPrefetching() { - guard !isPrefetchingNeeded else { return } - isPrefetchingNeeded = true - fetchThumbnailIfNeeded() - } - - func cancelPrefetching() { - guard isPrefetchingNeeded else { return } - isPrefetchingNeeded = false - cancelThumbnailRequestIfNeeded() - } - - // MARK: - Thumbnail - - private func fetchThumbnailIfNeeded() { - guard isVisible || isPrefetchingNeeded else { - return - } - guard imageTask == nil else { - return // Already loading - } - guard getCachedThubmnail() == nil else { - return // Already cached in memory - } - imageTask = Task { @MainActor [service, media, weak self] in - do { - let image = try await service.thumbnail(for: media) - self?.didFinishLoading(with: image) - } catch { - self?.didFinishLoading(with: nil) - } - } - } - - private func cancelThumbnailRequestIfNeeded() { - guard !isVisible && !isPrefetchingNeeded else { return } - imageTask?.cancel() - imageTask = nil - } - - private func didFinishLoading(with image: UIImage?) { - if let image { - cache.setImage(image, forKey: makeCacheKey(for: media)) - } - if !Task.isCancelled { - if let image { - onImageLoaded?(image) - } - imageTask = nil - } - } - - /// Returns the image from the memory cache. - func getCachedThubmnail() -> UIImage? { - cache.getImage(forKey: makeCacheKey(for: media)) - } - - private func makeCacheKey(for media: Media) -> String { - "thumbnail-\(media.objectID)" - } - - // Monitors thumbnails generated by `MediaImportService`. - private func didUpdateLocalThumbnail() { - guard media.remoteStatus != .sync, media.localURL != nil else { return } - fetchThumbnailIfNeeded() - } - - // MARK: - Status - - private func updateOverlayState() { - switch media.remoteStatus { - case .pushing, .processing: - self.overlayState = .indeterminate - case .failed: - self.overlayState = .retry - case .sync: - self.overlayState = nil - default: - break - } - } -} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift new file mode 100644 index 000000000000..6936db800b0b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift @@ -0,0 +1,192 @@ +import UIKit +import Combine +import Gifu + +final class SiteMediaCollectionCell: UICollectionViewCell, Reusable { + private let imageView = GIFImageView() + private let overlayView = CircularProgressView() + private let placeholderView = UIView() + private var durationView: SiteMediaVideoDurationView? + private var documentInfoView: SiteMediaDocumentInfoView? + private var badgeView: SiteMediaCollectionCellBadgeView? + + private var viewModel: SiteMediaCollectionCellViewModel? + private var cancellables: [AnyCancellable] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + + placeholderView.backgroundColor = .secondarySystemBackground + + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.accessibilityIgnoresInvertColors = true + + overlayView.backgroundColor = .neutral(.shade70).withAlphaComponent(0.5) + + contentView.addSubview(placeholderView) + placeholderView.translatesAutoresizingMaskIntoConstraints = false + contentView.pinSubviewToAllEdges(placeholderView) + + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + contentView.pinSubviewToAllEdges(imageView) + + contentView.addSubview(overlayView) + overlayView.translatesAutoresizingMaskIntoConstraints = false + contentView.pinSubviewToAllEdges(overlayView) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + cancellables = [] + viewModel?.onDisappear() + viewModel = nil + + imageView.prepareForReuse() + imageView.image = nil + imageView.alpha = 0 + placeholderView.alpha = 1 + badgeView?.isHidden = true + durationView?.isHidden = true + documentInfoView?.isHidden = true + } + + func configure(viewModel: SiteMediaCollectionCellViewModel) { + self.viewModel = viewModel + + switch viewModel.mediaType { + case .image, .video: + if let image = viewModel.getCachedThubmnail() { + // Display with no animations. It should happen often thanks to prefetching. + setImage(image) + } else { + let mediaID = viewModel.mediaID + viewModel.onImageLoaded = { [weak self] in + self?.didLoadImage($0, for: mediaID) + } + } + case .document, .powerpoint, .audio: + getDocumentInfoView().configure(viewModel) + getDocumentInfoView().isHidden = false + @unknown default: + break + } + + viewModel.$overlayState.sink { [overlayView] in + if let state = $0 { + overlayView.state = state + overlayView.isHidden = false + } else { + overlayView.isHidden = true + } + }.store(in: &cancellables) + + viewModel.$badgeText.sink { [weak self] text in + guard let self else { return } + if let text { + let badgeView = self.getBadgeView() + badgeView.isHidden = false + badgeView.textLabel.text = text + } else { + self.badgeView?.isHidden = true + } + }.store(in: &cancellables) + + viewModel.$durationText.sink { [weak self] text in + guard let self else { return } + if let text { + let durationView = self.getDurationView() + durationView.isHidden = false + durationView.textLabel.text = text + } else { + self.durationView?.isHidden = true + } + }.store(in: &cancellables) + + configureAccessibility(viewModel) + + viewModel.onAppear() + } + + // MARK: - Thumbnails + + private func didLoadImage(_ image: UIImage, for mediaID: TaggedManagedObjectID) { + assert(Thread.isMainThread) + + guard viewModel?.mediaID == mediaID else { return } + setImage(image) + } + + private func setImage(_ image: UIImage) { + if let gif = image as? AnimatedImageWrapper, let data = gif.gifData { + imageView.animate(withGIFData: data) + } else { + imageView.image = image + } + imageView.alpha = 1 + placeholderView.alpha = 0 + } + + // MARK: - Accessibility + + private func configureAccessibility(_ viewModel: SiteMediaCollectionCellViewModel) { + isAccessibilityElement = true + accessibilityLabel = viewModel.accessibilityLabel + accessibilityHint = viewModel.accessibilityHint + } + + // MARK: - Helpers + + private func getDocumentInfoView() -> SiteMediaDocumentInfoView { + if let documentInfoView { + return documentInfoView + } + let documentInfoView = SiteMediaDocumentInfoView() + contentView.addSubview(documentInfoView) + documentInfoView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + documentInfoView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0), + documentInfoView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0), + documentInfoView.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 4), + documentInfoView.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -4) + ]) + self.documentInfoView = documentInfoView + return documentInfoView + } + + private func getDurationView() -> SiteMediaVideoDurationView { + if let durationView { + return durationView + } + let durationView = SiteMediaVideoDurationView() + contentView.addSubview(durationView) + durationView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + durationView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0), + durationView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0) + ]) + self.durationView = durationView + return durationView + } + + private func getBadgeView() -> SiteMediaCollectionCellBadgeView { + if let badgeView { + return badgeView + } + let badgeView = SiteMediaCollectionCellBadgeView() + contentView.addSubview(badgeView) + badgeView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + badgeView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4), + badgeView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4) + ]) + self.badgeView = badgeView + return badgeView + } +} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCellBadgeView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCellBadgeView.swift similarity index 95% rename from WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCellBadgeView.swift rename to WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCellBadgeView.swift index b0a67d81f727..b710bd06c500 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaCollectionCellBadgeView.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCellBadgeView.swift @@ -1,6 +1,6 @@ import UIKit -final class MediaCollectionCellBadgeView: UIView { +final class SiteMediaCollectionCellBadgeView: UIView { let textLabel = UILabel() override init(frame: CGRect) { diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCellViewModel.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCellViewModel.swift new file mode 100644 index 000000000000..e85fb2e10dea --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCellViewModel.swift @@ -0,0 +1,206 @@ +import UIKit + +final class SiteMediaCollectionCellViewModel { + var onImageLoaded: ((UIImage) -> Void)? + @Published private(set) var overlayState: CircularProgressView.State? + @Published private(set) var durationText: String? + @Published var badgeText: String? + var filename: String? { media.filename } + let mediaID: TaggedManagedObjectID + let mediaType: MediaType + + private let media: Media + private let service: MediaImageService + private let cache: MemoryCache + private var isVisible = false + private var isPrefetchingNeeded = false + private var imageTask: Task? + private var observations: [NSKeyValueObservation] = [] + + deinit { + imageTask?.cancel() + } + + init(media: Media, + service: MediaImageService = .shared, + cache: MemoryCache = .shared) { + self.mediaID = TaggedManagedObjectID(media) + self.media = media + self.mediaType = media.mediaType + self.service = service + self.cache = cache + + if media.mediaType == .video { + observations.append(media.observe(\.length, options: [.initial, .new]) { [weak self] media, _ in + // Using `rounded()` to match the behavior of the Photos app + self?.durationText = makeString(forDuration: media.duration().rounded()) + }) + } + + observations.append(media.observe(\.remoteStatusNumber, options: [.new]) { [weak self] _, _ in + self?.updateOverlayState() + }) + + // No sure why but `.initial` didn't work. + self.updateOverlayState() + + observations.append(media.observe(\.localURL, options: [.new]) { [weak self] media, _ in + self?.didUpdateLocalThumbnail() + }) + } + + // MARK: - View Lifecycle + + func onAppear() { + guard !isVisible else { return } + isVisible = true + fetchThumbnailIfNeeded() + } + + func onDisappear() { + guard isVisible else { return } + isVisible = false + cancelThumbnailRequestIfNeeded() + } + + func startPrefetching() { + guard !isPrefetchingNeeded else { return } + isPrefetchingNeeded = true + fetchThumbnailIfNeeded() + } + + func cancelPrefetching() { + guard isPrefetchingNeeded else { return } + isPrefetchingNeeded = false + cancelThumbnailRequestIfNeeded() + } + + // MARK: - Thumbnail + + private func fetchThumbnailIfNeeded() { + guard isVisible || isPrefetchingNeeded else { + return + } + guard imageTask == nil else { + return // Already loading + } + guard getCachedThubmnail() == nil else { + return // Already cached in memory + } + imageTask = Task { @MainActor [service, media, weak self] in + do { + let image = try await service.thumbnail(for: media) + self?.didFinishLoading(with: image) + } catch { + self?.didFinishLoading(with: nil) + } + } + } + + private func cancelThumbnailRequestIfNeeded() { + guard !isVisible && !isPrefetchingNeeded else { return } + imageTask?.cancel() + imageTask = nil + } + + private func didFinishLoading(with image: UIImage?) { + if let image { + cache.setImage(image, forKey: makeCacheKey(for: media)) + } + if !Task.isCancelled { + if let image { + onImageLoaded?(image) + } + imageTask = nil + } + } + + /// Returns the image from the memory cache. + func getCachedThubmnail() -> UIImage? { + cache.getImage(forKey: makeCacheKey(for: media)) + } + + private func makeCacheKey(for media: Media) -> String { + "thumbnail-\(media.objectID)" + } + + // Monitors thumbnails generated by `MediaImportService`. + private func didUpdateLocalThumbnail() { + guard media.remoteStatus != .sync, media.localURL != nil else { return } + fetchThumbnailIfNeeded() + } + + // MARK: - State + + private func updateOverlayState() { + switch media.remoteStatus { + case .pushing, .processing: + self.overlayState = .indeterminate + case .failed: + self.overlayState = .retry + case .sync: + self.overlayState = nil + default: + break + } + } + + // MARK: - Accessibility + + var accessibilityLabel: String? { + let formattedDate = media.creationDate.map(accessibilityDateFormatter.string) ?? Strings.accessibilityUnknownCreationDate + + switch mediaType { + case .image: + return String(format: Strings.accessibilityLabelImage, formattedDate) + case .video: + return String(format: Strings.accessibilityLabelVideo, formattedDate) + case .audio: + return String(format: Strings.accessibilityLabelAudio, formattedDate) + case .document, .powerpoint: + return String(format: Strings.accessibilityLabelDocument, media.filename ?? formattedDate) + @unknown default: + return nil + } + } + + var accessibilityHint: String { Strings.accessibilityHint } +} + +private enum Strings { + static let accessibilityUnknownCreationDate = NSLocalizedString("siteMedia.accessibilityUnknownCreationDate", value: "Unknown creation date", comment: "Accessibility label to use when creation date from media asset is not know.") + static let accessibilityLabelImage = NSLocalizedString("siteMedia.accessibilityLabelImage", value: "Image, %@", comment: "Accessibility label for image thumbnails in the media collection view. The parameter is the creation date of the image.") + static let accessibilityLabelVideo = NSLocalizedString("siteMedia.accessibilityLabelVideo", value: "Video, %@", comment: "Accessibility label for video thumbnails in the media collection view. The parameter is the creation date of the video.") + static let accessibilityLabelAudio = NSLocalizedString("siteMedia.accessibilityLabelAudio", value: "Audio, %@", comment: "Accessibility label for audio items in the media collection view. The parameter is the creation date of the audio.") + static let accessibilityLabelDocument = NSLocalizedString("siteMedia.accessibilityLabelDocument", value: "Document, %@", comment: "Accessibility label for other media items in the media collection view. The parameter is the filename file.") + static let accessibilityHint = NSLocalizedString("siteMedia.cellAccessibilityHint", value: "Select media.", comment: "Accessibility hint for actions when displaying media items.") +} + +private let accessibilityDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.doesRelativeDateFormatting = true + formatter.dateStyle = .full + formatter.timeStyle = .short + return formatter +}() + +// MARK: - Helpers (Duration Formatter) + +private func makeString(forDuration duration: TimeInterval) -> String? { + let hours = Int(duration / 3600) + if hours > 0 { + return longDurationFormatter.string(from: duration) + } else { + return shortDurationFormatter.string(from: duration) + } +} + +private let longDurationFormatter = makeFormatter(units: [.hour, .minute, .second]) +private let shortDurationFormatter = makeFormatter(units: [.minute, .second]) + +private func makeFormatter(units: NSCalendar.Unit) -> DateComponentsFormatter { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = units + return formatter +} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaDocumentInfoView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaDocumentInfoView.swift new file mode 100644 index 000000000000..def8cba3edcb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaDocumentInfoView.swift @@ -0,0 +1,42 @@ +import UIKit + +final class SiteMediaDocumentInfoView: UIView { + let iconView = UIImageView() + let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + iconView.tintColor = .label + + titleLabel.font = .systemFont(ofSize: 12) + titleLabel.textColor = .label + titleLabel.lineBreakMode = .byTruncatingMiddle + + let stackView = UIStackView(arrangedSubviews: [iconView, titleLabel]) + stackView.alignment = .center + stackView.axis = .vertical + stackView.spacing = 4 + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + pinSubviewToAllEdges(stackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(_ viewModel: SiteMediaCollectionCellViewModel) { + switch viewModel.mediaType { + case .document, .powerpoint: + iconView.image = .gridicon(.pages) + titleLabel.text = viewModel.filename + case .audio: + iconView.image = .gridicon(.audio) + titleLabel.text = viewModel.filename + default: + break + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaVideoDurationView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaVideoDurationView.swift new file mode 100644 index 000000000000..e97c038eb002 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaVideoDurationView.swift @@ -0,0 +1,40 @@ +import UIKit + +final class SiteMediaVideoDurationView: UIView { + let textLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.33 + layer.shadowRadius = 8 + + textLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold) + textLabel.textColor = UIColor.white + + addSubview(textLabel) + textLabel.translatesAutoresizingMaskIntoConstraints = false + + // The insets are designed to make the shadow layer look good and don't take + // too much space in the video. + NSLayoutConstraint.activate([ + textLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32), + textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + textLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), + textLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + ]) + + clipsToBounds = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + layer.shadowPath = CGPath(ellipseIn: bounds.offsetBy(dx: bounds.width / 2, dy: bounds.height / 2), transform: nil) + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index fbbd610fce49..fe2775ecfc83 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -377,8 +377,12 @@ 0A9610F928B2E56300076EBA /* UserSuggestion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */; }; 0A9610FA28B2E56300076EBA /* UserSuggestion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */; }; 0A9687BC28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9687BB28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift */; }; - 0C01A6EA2AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C01A6E92AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift */; }; - 0C01A6EB2AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C01A6E92AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift */; }; + 0C01A6EA2AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C01A6E92AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift */; }; + 0C01A6EB2AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C01A6E92AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift */; }; + 0C0453282AC73343003079C8 /* SiteMediaVideoDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0453272AC73343003079C8 /* SiteMediaVideoDurationView.swift */; }; + 0C0453292AC73343003079C8 /* SiteMediaVideoDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0453272AC73343003079C8 /* SiteMediaVideoDurationView.swift */; }; + 0C04532B2AC77245003079C8 /* SiteMediaDocumentInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C04532A2AC77245003079C8 /* SiteMediaDocumentInfoView.swift */; }; + 0C04532C2AC77245003079C8 /* SiteMediaDocumentInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C04532A2AC77245003079C8 /* SiteMediaDocumentInfoView.swift */; }; 0C0AE7592A8FAD6A007D9D6C /* MediaPickerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0AE7582A8FAD6A007D9D6C /* MediaPickerMenu.swift */; }; 0C0AE75A2A8FAD6A007D9D6C /* MediaPickerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0AE7582A8FAD6A007D9D6C /* MediaPickerMenu.swift */; }; 0C0D3B0D2A4C79DE0050A00D /* BlazeCampaignsStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D3B0C2A4C79DE0050A00D /* BlazeCampaignsStream.swift */; }; @@ -443,10 +447,10 @@ 0C8FC9AC2A8C57930059DCE4 /* test-webp.webp in Resources */ = {isa = PBXBuildFile; fileRef = 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */; }; 0CA1C8C12A940EE300F691EE /* AvatarMenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA1C8C02A940EE300F691EE /* AvatarMenuController.swift */; }; 0CA1C8C22A940EE300F691EE /* AvatarMenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA1C8C02A940EE300F691EE /* AvatarMenuController.swift */; }; - 0CAE8EF22A9E9E8D0073EEB9 /* MediaCollectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF12A9E9E8D0073EEB9 /* MediaCollectionCell.swift */; }; - 0CAE8EF32A9E9E8D0073EEB9 /* MediaCollectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF12A9E9E8D0073EEB9 /* MediaCollectionCell.swift */; }; - 0CAE8EF62A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF52A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift */; }; - 0CAE8EF72A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF52A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift */; }; + 0CAE8EF22A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF12A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift */; }; + 0CAE8EF32A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF12A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift */; }; + 0CAE8EF62A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF52A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift */; }; + 0CAE8EF72A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAE8EF52A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift */; }; 0CB4056B29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; 0CB4056C29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; 0CB4056E29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */; }; @@ -6056,7 +6060,9 @@ 0A69300A28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModelTests.swift; sourceTree = ""; }; 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserSuggestion+Comparable.swift"; sourceTree = ""; }; 0A9687BB28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModelMock.swift; sourceTree = ""; }; - 0C01A6E92AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCollectionCellBadgeView.swift; sourceTree = ""; }; + 0C01A6E92AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMediaCollectionCellBadgeView.swift; sourceTree = ""; }; + 0C0453272AC73343003079C8 /* SiteMediaVideoDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMediaVideoDurationView.swift; sourceTree = ""; }; + 0C04532A2AC77245003079C8 /* SiteMediaDocumentInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMediaDocumentInfoView.swift; sourceTree = ""; }; 0C0AE7582A8FAD6A007D9D6C /* MediaPickerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerMenu.swift; sourceTree = ""; }; 0C0D3B0C2A4C79DE0050A00D /* BlazeCampaignsStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignsStream.swift; sourceTree = ""; }; 0C23F3352AC4AD3400EE6117 /* SiteMediaSelectionTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMediaSelectionTitleView.swift; sourceTree = ""; }; @@ -6096,8 +6102,8 @@ 0C8FC9A92A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProviderMediaExporterTests.swift; sourceTree = ""; }; 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = "test-webp.webp"; sourceTree = ""; }; 0CA1C8C02A940EE300F691EE /* AvatarMenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarMenuController.swift; sourceTree = ""; }; - 0CAE8EF12A9E9E8D0073EEB9 /* MediaCollectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCollectionCell.swift; sourceTree = ""; }; - 0CAE8EF52A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCollectionCellViewModel.swift; sourceTree = ""; }; + 0CAE8EF12A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMediaCollectionCell.swift; sourceTree = ""; }; + 0CAE8EF52A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteMediaCollectionCellViewModel.swift; sourceTree = ""; }; 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationService.swift; sourceTree = ""; }; 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationServiceTests.swift; sourceTree = ""; }; 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationViewModel.swift; sourceTree = ""; }; @@ -10110,10 +10116,12 @@ 0C23F3332AC49C1000EE6117 /* Views */ = { isa = PBXGroup; children = ( - 0CAE8EF12A9E9E8D0073EEB9 /* MediaCollectionCell.swift */, - 0C01A6E92AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift */, - 0CAE8EF52A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift */, + 0CAE8EF12A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift */, + 0C01A6E92AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift */, + 0CAE8EF52A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift */, 0C23F3352AC4AD3400EE6117 /* SiteMediaSelectionTitleView.swift */, + 0C0453272AC73343003079C8 /* SiteMediaVideoDurationView.swift */, + 0C04532A2AC77245003079C8 /* SiteMediaDocumentInfoView.swift */, ); path = Views; sourceTree = ""; @@ -21402,7 +21410,7 @@ 80A2153D29C35197002FE8EB /* StaticScreensTabBarWrapper.swift in Sources */, 1E485A90249B61440000A253 /* GutenbergRequestAuthenticator.swift in Sources */, 084FC3BC299155C900A17BCF /* JetpackOverlayCoordinator.swift in Sources */, - 0CAE8EF62A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift in Sources */, + 0CAE8EF62A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift in Sources */, 8370D10A11FA499A009D650F /* WPTableViewActivityCell.m in Sources */, E1B912891BB01288003C25B9 /* PeopleViewController.swift in Sources */, 3FEC241525D73E8B007AFE63 /* ConfettiView.swift in Sources */, @@ -21434,7 +21442,7 @@ 8B36256625A60CCA00D7CCE3 /* BackupListViewController.swift in Sources */, FAA4013427B52455009E1137 /* DashboardQuickActionCell.swift in Sources */, 32A218D8251109DB00D1AE6C /* ReaderReportPostAction.swift in Sources */, - 0C01A6EA2AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift in Sources */, + 0C01A6EA2AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift in Sources */, FA681F8A25CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift in Sources */, 081E4B4C281C019A0085E89C /* TooltipAnchor.swift in Sources */, 3F851415260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift in Sources */, @@ -21789,7 +21797,7 @@ C3234F5427EBBACA004ADB29 /* SiteIntentVertical.swift in Sources */, 983DBBAB22125DD500753988 /* StatsTableFooter.swift in Sources */, 85D239AE1AE5A5FC0074768D /* BlogSyncFacade.m in Sources */, - 0CAE8EF22A9E9E8D0073EEB9 /* MediaCollectionCell.swift in Sources */, + 0CAE8EF22A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift in Sources */, 8B0732F3242BF99B00E7FBD3 /* PrepublishingNavigationController.swift in Sources */, 5D97C2F315CAF8D8009B44DD /* UINavigationController+KeyboardFix.m in Sources */, 4034FDEA2007C42400153B87 /* ExpandableCell.swift in Sources */, @@ -22456,6 +22464,7 @@ FE76C5E0293A63A800573C92 /* UIApplication+AppAvailability.swift in Sources */, 9A341E5621997A340036662E /* BlogAuthor.swift in Sources */, C7234A3A2832BA240045C63F /* QRLoginCoordinator.swift in Sources */, + 0C04532B2AC77245003079C8 /* SiteMediaDocumentInfoView.swift in Sources */, 9A341E5721997A340036662E /* Blog+BlogAuthors.swift in Sources */, F4D829702931097900038726 /* DashboardMigrationSuccessCell+WordPress.swift in Sources */, 7E3AB3DB20F52654001F33B6 /* ActivityContentStyles.swift in Sources */, @@ -22640,6 +22649,7 @@ 938CF3DC1EF1BE6800AF838E /* CocoaLumberjack.swift in Sources */, 740BD8351A0D4C3600F04D18 /* WPUploadStatusButton.m in Sources */, 7EAD7CD0206D761200BEDCFD /* MediaExternalExporter.swift in Sources */, + 0C0453282AC73343003079C8 /* SiteMediaVideoDurationView.swift in Sources */, 837B49D9283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift in Sources */, 436D56212117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift in Sources */, F53FF3A823EA723D001AD596 /* ActionRow.swift in Sources */, @@ -23671,7 +23681,7 @@ FABB21032602FC2C00C8785C /* JetpackActivityLogViewController.swift in Sources */, 17870A712816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift in Sources */, C33A5ADC2935848F00961E3A /* MigrationAppDetection.swift in Sources */, - 0CAE8EF32A9E9E8D0073EEB9 /* MediaCollectionCell.swift in Sources */, + 0CAE8EF32A9E9E8D0073EEB9 /* SiteMediaCollectionCell.swift in Sources */, 80D9D00429EF4C7F00FE3400 /* DashboardPageCreationCell.swift in Sources */, DC772AF6282009BA00664C02 /* StatsLineChartView.swift in Sources */, FABB21042602FC2C00C8785C /* FormattableMediaContent.swift in Sources */, @@ -24714,7 +24724,7 @@ FABB24032602FC2C00C8785C /* ReaderCSS.swift in Sources */, FABB24042602FC2C00C8785C /* SafariActivity.m in Sources */, FABB24052602FC2C00C8785C /* WordPress-37-38.xcmappingmodel in Sources */, - 0C01A6EB2AB37F0F009F7145 /* MediaCollectionCellBadgeView.swift in Sources */, + 0C01A6EB2AB37F0F009F7145 /* SiteMediaCollectionCellBadgeView.swift in Sources */, FABB24062602FC2C00C8785C /* UploadOperation.swift in Sources */, FABB24072602FC2C00C8785C /* LayoutPickerAnalyticsEvent.swift in Sources */, FABB24082602FC2C00C8785C /* ActivityRange.swift in Sources */, @@ -24734,6 +24744,7 @@ 084FC3BD299155CA00A17BCF /* JetpackOverlayCoordinator.swift in Sources */, C7F7ABD6261CED7A00CE547F /* JetpackAuthenticationManager.swift in Sources */, FABB24142602FC2C00C8785C /* ThemeBrowserSectionHeaderView.swift in Sources */, + 0C0453292AC73343003079C8 /* SiteMediaVideoDurationView.swift in Sources */, FABB24152602FC2C00C8785C /* SiteIconPickerPresenter.swift in Sources */, C79C307D26EA919F00E88514 /* ReferrerDetailsViewModel.swift in Sources */, 8BBC778C27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */, @@ -24810,6 +24821,7 @@ FABB24482602FC2C00C8785C /* MyProfileViewController.swift in Sources */, 0C2C83FE2A6EBD3F00A3ACD9 /* StatsInsightsCache.swift in Sources */, FAD7626529F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift in Sources */, + 0C04532C2AC77245003079C8 /* SiteMediaDocumentInfoView.swift in Sources */, 3FE3D1FF26A6F56700F3CD10 /* Comment+Interface.swift in Sources */, FABB24492602FC2C00C8785C /* CreateButtonActionSheet.swift in Sources */, FABB244A2602FC2C00C8785C /* ReaderStreamViewController+Ghost.swift in Sources */, @@ -25415,7 +25427,7 @@ F1C740C026B1D4D2005D0809 /* StoreSandboxSecretScreen.swift in Sources */, 801D94F02919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift in Sources */, FABB25FF2602FC2C00C8785C /* RegisterDomainDetailsViewModel+RowDefinitions.swift in Sources */, - 0CAE8EF72A9E9EE30073EEB9 /* MediaCollectionCellViewModel.swift in Sources */, + 0CAE8EF72A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift in Sources */, 98E082A02637545C00537BF1 /* PostService+Likes.swift in Sources */, FABB26002602FC2C00C8785C /* GravatarProfile.swift in Sources */, FABB26012602FC2C00C8785C /* ReaderShareAction.swift in Sources */,