mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
142 lines
5.2 KiB
Swift
142 lines
5.2 KiB
Swift
//
|
|
// Copyright 2026 Element Creations Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Compound
|
|
import SwiftUI
|
|
|
|
struct GalleryItemTileView: View {
|
|
let item: GalleryItem
|
|
/// The item's position within the gallery, announced instead of its filename.
|
|
let position: Int
|
|
let mediaProvider: MediaProviderProtocol?
|
|
let contentScannerService: ContentScannerServiceProtocol?
|
|
let overflowCount: Int
|
|
let onTap: () -> Void
|
|
|
|
private var accessibilityLabel: String {
|
|
if overflowCount > 0 {
|
|
L10n.a11yGalleryMoreMedia(overflowCount)
|
|
} else if item.isVideo {
|
|
L10n.a11yGalleryVideo(position)
|
|
} else if item.isImage {
|
|
L10n.a11yGalleryImage(position)
|
|
} else {
|
|
L10n.a11yGalleryAttachment(position)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
// Overflow above the scanner: shows on any scan state, survives content updates.
|
|
ZStack {
|
|
// containerShowsFailure: false keeps an unsafe tile local, not the whole bubble.
|
|
ContentScanningView(contentScannerService: contentScannerService,
|
|
mediaSource: item.mediaSource,
|
|
thumbnailSource: item.thumbnailSource,
|
|
containerShowsFailure: false) {
|
|
// Clear base so the image fills/clips to the grid's tile size, not its own.
|
|
Color.clear
|
|
.overlay { content }
|
|
.clipped()
|
|
.overlay(alignment: .bottom) {
|
|
if item.isVideo {
|
|
videoOverlay
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(accessibilityLabel)
|
|
} scanningContent: {
|
|
ScanningMediaEventsTimelineView()
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(L10n.a11yGalleryScanning(position))
|
|
} unsafeContent: { failure in
|
|
UnsafeMediaEventsTimelineView(failure: failure)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(L10n.a11yGalleryFailedScan(position))
|
|
}
|
|
|
|
if overflowCount > 0 {
|
|
overflowOverlay
|
|
}
|
|
}
|
|
// Tappable in every scan state, so that an unsafe item can't hide the rest of the gallery.
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onTap() }
|
|
// One element per tile, labelled by whichever state is on show.
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if let source = item.thumbnailSource ?? item.mediaSource {
|
|
LoadableImage(mediaSource: source,
|
|
mediaType: .timelineItem(uniqueID: item.id.timelineItemID.uniqueID),
|
|
blurhash: item.blurhash,
|
|
size: item.size,
|
|
mediaProvider: mediaProvider) { imageView in
|
|
imageView
|
|
.aspectRatio(contentMode: .fill)
|
|
} placeholder: {
|
|
placeholder
|
|
}
|
|
} else {
|
|
iconFallback
|
|
}
|
|
}
|
|
|
|
private var placeholder: some View {
|
|
Color.compound.bgSubtleSecondary.opacity(0.9)
|
|
}
|
|
|
|
private var iconFallback: some View {
|
|
ZStack {
|
|
Color.compound.bgSubtleSecondary
|
|
CompoundIcon(item.isAudio ? \.audio : \.attachment)
|
|
.foregroundStyle(.compound.iconPrimary)
|
|
}
|
|
}
|
|
|
|
private var videoOverlay: some View {
|
|
HStack(spacing: 0) {
|
|
CompoundIcon(\.videoCallSolid,
|
|
size: .xSmall,
|
|
relativeTo: .compound.bodyXSSemibold)
|
|
Spacer(minLength: 0)
|
|
if let duration = item.duration {
|
|
Text(formatted(duration: duration))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(8)
|
|
.frame(maxWidth: .infinity)
|
|
.font(.compound.bodyXSSemibold)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
.background {
|
|
LinearGradient(colors: [.clear, .compound.bgCanvasDefault],
|
|
startPoint: .top,
|
|
endPoint: .bottom)
|
|
}
|
|
}
|
|
|
|
private var overflowOverlay: some View {
|
|
ZStack {
|
|
Color.compound.bgCanvasDefault.opacity(0.7)
|
|
Text("+\(overflowCount)")
|
|
.font(.compound.headingSMSemibold)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
}
|
|
.allowsHitTesting(false) // Let the tap fall through to the tile below.
|
|
.accessibilityHidden(true) // The count is already announced by the tile's label.
|
|
}
|
|
|
|
private func formatted(duration: TimeInterval) -> String {
|
|
let duration = Duration.seconds(duration)
|
|
let pattern: Duration.TimeFormatStyle.Pattern = duration >= .seconds(3600) ? .hourMinuteSecond : .minuteSecond
|
|
return duration.formatted(.time(pattern: pattern))
|
|
}
|
|
}
|