-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Grid view in the new media screen #25561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0c38c49
c461d20
2becfee
a94435d
0c8a24c
cbfa85e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import Foundation | ||
|
|
||
| /// Formats a video duration (seconds) as `m:ss` for durations under one hour | ||
| /// and `h:mm:ss` from one hour up. **Intentionally locale-neutral**: V1's | ||
| /// `DateComponentsFormatter`-based output varies in non-Latin-numeral | ||
| /// locales (Arabic, Hindi, etc.), but the duration badge uses a | ||
| /// `.monospaced` font and reads more like a timecode than a sentence — a | ||
| /// stable `digit:digit` output is the better fit. This is a small, | ||
| /// deliberate deviation from V1's `SiteMediaCollectionCellViewModel.swift`'s | ||
| /// `makeString(forDuration:)`. | ||
| enum MediaGridDuration { | ||
| static func string(forSeconds seconds: UInt32) -> String { | ||
| let total = Int(seconds) | ||
| let hours = total / 3600 | ||
| let minutes = (total % 3600) / 60 | ||
| let secs = total % 60 | ||
| if hours > 0 { | ||
| return String(format: "%d:%02d:%02d", hours, minutes, secs) | ||
| } | ||
| return String(format: "%d:%02d", minutes, secs) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import Foundation | ||
| import CoreGraphics | ||
| import WordPressAPI | ||
| import WordPressAPIInternal | ||
|
|
||
| /// Display model for a single grid cell. | ||
| struct MediaGridItem: Identifiable, Equatable { | ||
| let id: Int64 | ||
| let kind: MediaKind? | ||
| let displayTitle: String | ||
| let thumbnailURL: URL? // image or video kind; the cell picks the right CachedAsyncImage initializer based on kind | ||
| let aspectRatio: CGFloat? // image kind only; width / height | ||
| let durationString: String? // video kind only | ||
| let state: State | ||
| let accessibilityLabel: String | ||
|
|
||
| enum State: Equatable { | ||
| case loaded(isUpToDate: Bool) | ||
| case loading | ||
| case error(message: String) | ||
| } | ||
|
|
||
| init(item: MediaMetadataCollectionItem) { | ||
| switch item.state { | ||
| case .fresh(let entity): | ||
| self.init(media: entity.data, id: item.id, state: .loaded(isUpToDate: true)) | ||
| case .stale(let entity): | ||
| self.init(media: entity.data, id: item.id, state: .loaded(isUpToDate: false)) | ||
| case .fetchingWithData(let entity): | ||
| self.init(media: entity.data, id: item.id, state: .loading) | ||
| case .failedWithData(let message, let entity): | ||
| self.init(media: entity.data, id: item.id, state: .error(message: message)) | ||
| case .fetching, .missing: | ||
| self.init(placeholderID: item.id, state: .loading) | ||
| case .failed(let message): | ||
| self.init(placeholderID: item.id, state: .error(message: message)) | ||
| } | ||
| } | ||
|
|
||
| /// Designated initializer for data-bearing states. Initializes every | ||
| /// stored property exactly once. | ||
| private init(media: MediaWithEditContext, id: Int64, state: State) { | ||
| let payload = media.mediaDetails.parseAsMimeType(mimeType: media.mimeType) | ||
| let kind = payload.flatMap(MediaKind.init(payload:)) ?? .document | ||
|
|
||
| self.id = id | ||
| self.kind = kind | ||
| self.displayTitle = MediaGridItem.makeTitle(media: media) | ||
| self.state = state | ||
| self.accessibilityLabel = MediaGridItem.makeAccessibilityLabel(media: media, kind: kind) | ||
|
|
||
| switch payload { | ||
| case .image(let imageDetails): | ||
| self.thumbnailURL = MediaThumbnailURL.pick(from: imageDetails, sourceUrl: media.sourceUrl) | ||
| if imageDetails.width > 0, imageDetails.height > 0 { | ||
| self.aspectRatio = CGFloat(imageDetails.width) / CGFloat(imageDetails.height) | ||
| } else { | ||
| self.aspectRatio = nil | ||
| } | ||
| self.durationString = nil | ||
| case .video(let videoDetails): | ||
| // For video, `thumbnailURL` carries the video file URL itself — | ||
| // the cell renders it via `CachedAsyncImage(videoUrl:)`, which | ||
| // extracts a frame for the thumbnail (V1 parity). | ||
| self.thumbnailURL = URL(string: media.sourceUrl) | ||
| self.aspectRatio = nil | ||
| self.durationString = MediaGridDuration.string(forSeconds: videoDetails.length) | ||
| case .audio, .document, .none: | ||
| self.thumbnailURL = nil | ||
| self.aspectRatio = nil | ||
| self.durationString = nil | ||
| } | ||
| } | ||
|
|
||
| /// Designated initializer for payload-less states. Initializes every | ||
| /// stored property exactly once. The accessibility label branches on | ||
| /// `state` because the same initializer covers both `.fetching` / | ||
| /// `.missing` (genuinely loading) and `.failed` (error without payload): | ||
| /// VoiceOver shouldn't hear "Loading media" while the cell shows an | ||
| /// error icon. | ||
| private init(placeholderID id: Int64, state: State) { | ||
| self.id = id | ||
| self.kind = nil // unknown: no payload to determine the media type | ||
| self.displayTitle = "" | ||
| self.thumbnailURL = nil | ||
| self.aspectRatio = nil | ||
| self.durationString = nil | ||
| self.state = state | ||
| switch state { | ||
| case .error: | ||
| self.accessibilityLabel = Strings.accessibilityErrorMedia | ||
| case .loading, .loaded: | ||
| self.accessibilityLabel = Strings.accessibilityLoadingMedia | ||
| } | ||
| } | ||
|
|
||
| private static func makeTitle(media: MediaWithEditContext) -> String { | ||
| let raw = (media.title.raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if !raw.isEmpty { return raw } | ||
| let slug = media.slug.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if !slug.isEmpty { return slug } | ||
| if let filename = filename(from: media.sourceUrl), !filename.isEmpty { | ||
| return filename | ||
| } | ||
| return Strings.untitled | ||
| } | ||
|
|
||
| private static func makeAccessibilityLabel(media: MediaWithEditContext, kind: MediaKind) -> String { | ||
| // `WpGmtDateTime` is a typealias for `Date` in the wordpress-rs Swift | ||
| // binding, so `media.dateGmt` is already a proper Date — no string | ||
| // parsing needed. The DateFormatter applies the user's locale + time | ||
| // zone, so a UTC dateGmt renders as local time, matching the V1 cell | ||
| // view-model's behavior. | ||
| let date = MediaGridItem.accessibilityDateFormatter.string(from: media.dateGmt) | ||
| switch kind { | ||
| case .image: | ||
| return String.localizedStringWithFormat(Strings.accessibilityLabelImage, date) | ||
| case .video: | ||
| return String.localizedStringWithFormat(Strings.accessibilityLabelVideo, date) | ||
| case .audio: | ||
| return String.localizedStringWithFormat(Strings.accessibilityLabelAudio, date) | ||
| case .document: | ||
| // V1 falls back to filename for documents; if filename can't be | ||
| // derived, use the date so the row is still describable. | ||
| let filenameOrDate = filename(from: media.sourceUrl) ?? date | ||
| return String.localizedStringWithFormat(Strings.accessibilityLabelDocument, filenameOrDate) | ||
| } | ||
| } | ||
|
|
||
| private static func filename(from sourceUrl: String) -> String? { | ||
| guard let url = URL(string: sourceUrl) else { return nil } | ||
| let last = url.lastPathComponent | ||
| return last.isEmpty ? nil : last | ||
| } | ||
|
|
||
| private static let accessibilityDateFormatter: DateFormatter = { | ||
| let formatter = DateFormatter() | ||
| formatter.doesRelativeDateFormatting = true | ||
| formatter.dateStyle = .full | ||
| formatter.timeStyle = .short | ||
| return formatter | ||
| }() | ||
| } | ||
|
|
||
| #if DEBUG | ||
| extension MediaGridItem { | ||
| /// Test-only: build an item with an explicit `kind`, bypassing FFI entity | ||
| /// construction. Exposed via `@testable import`. | ||
| init(testID id: Int64, kind: MediaKind?, state: State = .loaded(isUpToDate: true)) { | ||
| self.id = id | ||
| self.kind = kind | ||
| self.displayTitle = "" | ||
| self.thumbnailURL = nil | ||
| self.aspectRatio = nil | ||
| self.durationString = nil | ||
| self.state = state | ||
| self.accessibilityLabel = "" | ||
| } | ||
| } | ||
| #endif |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
| import WordPressAPIInternal | ||
|
|
||
| /// The enum itself is public so `MediaTrackerEvent.mediaLibraryFilterChanged(kind:)` | ||
| /// can carry it across the module boundary; the app-target analytics | ||
| /// adapter reads `rawValue` for its property dict. | ||
| public enum MediaKind: String, CaseIterable, Hashable, Sendable { | ||
| case image, video, audio, document | ||
|
|
||
| init?(payload: MediaDetailsPayload) { | ||
| switch payload { | ||
| case .image: self = .image | ||
| case .video: self = .video | ||
| case .audio: self = .audio | ||
| case .document: self = .document | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - UI helpers | ||
| // | ||
| // These properties live in the same file as the enum but in their own | ||
| // extension so they're easy to spot and so the base enum (used by the | ||
| // public analytics surface) doesn't pull in localized strings unnecessarily. | ||
|
|
||
| extension MediaKind { | ||
| var title: String { | ||
| switch self { | ||
| case .image: Strings.filterImages | ||
| case .video: Strings.filterVideos | ||
| case .audio: Strings.filterAudio | ||
| case .document: Strings.filterDocuments | ||
| } | ||
| } | ||
|
|
||
| var systemImageName: String { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is repeated in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not entirely? I think it's okay to leave a duplicate? This |
||
| switch self { | ||
| case .image: "photo" | ||
| case .video: "video" | ||
| case .audio: "waveform" | ||
| case .document: "folder" | ||
| } | ||
| } | ||
| } | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
| import WordPressAPIInternal | ||
|
|
||
| /// Picks a thumbnail URL from `ImageMediaDetails.sizes`, falling back through | ||
| /// a preference list and finally to `sourceUrl`. The 4-per-row phone grid | ||
| /// renders ~270px cells at @3x — `medium` (default 300px) is the closest | ||
| /// well-known size; `thumbnail` (150px) covers the case where only the small | ||
| /// image has been generated server-side. | ||
| enum MediaThumbnailURL { | ||
| private static let preferenceOrder = ["medium", "medium_large", "large", "thumbnail"] | ||
|
|
||
| static func pick(from imageDetails: ImageMediaDetails, sourceUrl: String) -> URL? { | ||
| for key in preferenceOrder { | ||
| if let scaled = imageDetails.sizes?[key], let url = URL(string: scaled.sourceUrl) { | ||
| return url | ||
| } | ||
| } | ||
| return URL(string: sourceUrl) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import Foundation | ||
| import UIKit | ||
| import WordPressShared | ||
|
|
||
| /// Read/write the V1 `mediaAspectRatioModeEnabled` UserDefaults key from | ||
| /// inside the module. The constant key lives in `WordPressShared` | ||
| /// (`UPRUConstants.mediaAspectRatioModeEnabledKey`); the convenience getter | ||
| /// the V1 host uses is on the app target's `UserPersistentRepositoryUtility` | ||
| /// extension, which the module can't reach — so we re-implement it locally. | ||
| /// Default matches V1: `.pad` users default to aspect-ratio mode on, | ||
| /// `.phone` to off. | ||
| enum AspectRatioPreference { | ||
| private static let key = UPRUConstants.mediaAspectRatioModeEnabledKey | ||
|
|
||
| static func load(defaults: UserDefaults = .standard) -> Bool { | ||
| if let value = defaults.object(forKey: key) as? Bool { return value } | ||
| return UIDevice.current.userInterfaceIdiom == .pad | ||
| } | ||
|
|
||
| static func save(_ value: Bool, defaults: UserDefaults = .standard) { | ||
| defaults.set(value, forKey: key) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use Swift's
Duration.TimeFormatStyle.Patternto accomplish this?Then it can be locale-aware, but still fixed-size?