mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Render image and video in galleries as a grid (#5929)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// 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
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let contentScannerService: ContentScannerServiceProtocol?
|
||||
let overflowCount: Int
|
||||
let onTap: () -> Void
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap() }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(item.filename)
|
||||
} scanningContent: {
|
||||
ScanningMediaEventsTimelineView()
|
||||
} unsafeContent: { failure in
|
||||
UnsafeMediaEventsTimelineView(failure: failure)
|
||||
}
|
||||
|
||||
if overflowCount > 0 {
|
||||
overflowOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let source = item.thumbnailSource ?? item.mediaSource {
|
||||
LoadableImage(mediaSource: source,
|
||||
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user