Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ let package = Package(
.product(name: "Logging", package: "swift-log")
]
),
.testTarget(
name: "WordPressMediaLibraryTests",
dependencies: [
.target(name: "WordPressMediaLibrary"),
.product(name: "WordPressAPI", package: "wordpress-rs")
]
),
.target(
name: "ShareExtensionCore",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public protocol MediaTracker {

public enum MediaTrackerEvent: Sendable {
case mediaLibraryOpened
case mediaLibraryFilterChanged(kind: MediaKind?) // nil = "All"
case mediaLibrarySearched(queryLength: Int) // fires AFTER 300ms debounce trailing edge; non-empty only
case mediaLibraryGridModeToggled(isAspectRatio: Bool)
}

/// No-op tracker for previews and module-internal default-construction.
Expand Down
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)
}
}
160 changes: 160 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaGridItem.swift
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
45 changes: 45 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift
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 {
switch self {
case .image: "photo"
case .video: "video"
case .audio: "waveform"
case .document: "folder"
}
}
}
66 changes: 0 additions & 66 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift

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)
}
}
Loading