IceCubesApp/Packages/MediaUI/Sources/MediaUI/MediaUIView.swift

221 lines
5.4 KiB
Swift
Raw Normal View History

2024-02-14 11:48:14 +00:00
import AVFoundation
2023-10-16 17:08:59 +00:00
import Models
2023-11-01 17:58:44 +00:00
import Nuke
2023-10-16 17:08:59 +00:00
import QuickLook
2023-11-01 17:58:44 +00:00
import SwiftUI
2023-10-16 17:08:59 +00:00
2023-10-18 10:19:39 +00:00
public struct MediaUIView: View, @unchecked Sendable {
private let data: [DisplayData]
private let initialItem: DisplayData?
@State private var scrolledItem: DisplayData?
2023-11-14 18:48:14 +00:00
@FocusState private var isFocused: Bool
2023-10-16 17:08:59 +00:00
public var body: some View {
NavigationStack {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(data) {
DisplayView(data: $0)
.containerRelativeFrame([.horizontal, .vertical])
.id($0)
2023-10-16 17:08:59 +00:00
}
}
.scrollTargetLayout()
}
2023-11-14 18:48:14 +00:00
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.leftArrow, action: {
scrollToPrevious()
return .handled
})
.onKeyPress(.rightArrow, action: {
scrollToNext()
return .handled
})
2023-10-16 17:08:59 +00:00
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $scrolledItem)
2023-10-16 17:08:59 +00:00
.toolbar {
if let item = scrolledItem {
MediaToolBar(data: item)
}
2023-10-16 17:08:59 +00:00
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
scrolledItem = initialItem
2023-11-14 18:48:14 +00:00
isFocused = true
2023-10-16 17:08:59 +00:00
}
}
}
}
public init(selectedAttachment: MediaAttachment, attachments: [MediaAttachment]) {
2023-11-01 17:58:44 +00:00
data = attachments.compactMap { DisplayData(from: $0) }
initialItem = DisplayData(from: selectedAttachment)
}
2023-12-18 07:22:59 +00:00
2023-11-14 18:48:14 +00:00
private func scrollToPrevious() {
if let scrolledItem, let index = data.firstIndex(of: scrolledItem), index > 0 {
withAnimation {
self.scrolledItem = data[index - 1]
}
}
}
2023-12-18 07:22:59 +00:00
2023-11-14 18:48:14 +00:00
private func scrollToNext() {
2023-12-18 07:22:59 +00:00
if let scrolledItem, let index = data.firstIndex(of: scrolledItem), index < data.count - 1 {
2023-11-14 18:48:14 +00:00
withAnimation {
self.scrolledItem = data[index + 1]
}
}
}
}
private struct MediaToolBar: ToolbarContent {
let data: DisplayData
var body: some ToolbarContent {
2024-01-09 18:06:54 +00:00
DismissToolbarItem()
QuickLookToolbarItem(itemUrl: data.url)
AltTextToolbarItem(alt: data.description)
SavePhotoToolbarItem(url: data.url, type: data.type)
ShareToolbarItem(url: data.url, type: data.type)
}
}
private struct DismissToolbarItem: ToolbarContent {
@Environment(\.dismiss) private var dismiss
var body: some ToolbarContent {
2023-10-18 10:19:39 +00:00
ToolbarItem(placement: .topBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle")
}
2024-01-09 18:06:54 +00:00
.opacity(0)
.keyboardShortcut(.cancelAction)
2023-10-18 10:19:39 +00:00
}
}
}
private struct AltTextToolbarItem: ToolbarContent {
let alt: String?
@State private var isAlertDisplayed = false
var body: some ToolbarContent {
2023-10-18 10:19:39 +00:00
ToolbarItem(placement: .topBarTrailing) {
2023-11-01 17:58:44 +00:00
if let alt {
2023-10-18 10:19:39 +00:00
Button {
isAlertDisplayed = true
2023-10-18 10:19:39 +00:00
} label: {
Text("status.image.alt-text.abbreviation")
}
.alert("status.editor.media.image-description",
2023-11-01 17:58:44 +00:00
isPresented: $isAlertDisplayed)
{
Button("alert.button.ok", action: {})
} message: {
Text(alt)
}
} else {
EmptyView()
2023-10-18 10:19:39 +00:00
}
}
}
}
private struct SavePhotoToolbarItem: ToolbarContent, @unchecked Sendable {
let url: URL
let type: DisplayType
@State private var state = SavingState.unsaved
var body: some ToolbarContent {
2023-10-18 10:19:39 +00:00
ToolbarItem(placement: .topBarTrailing) {
if type == .image {
2023-10-18 10:19:39 +00:00
Button {
Task {
state = .saving
2023-10-18 10:19:39 +00:00
if await saveImage(url: url) {
withAnimation {
state = .saved
2023-10-18 10:19:39 +00:00
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
state = .unsaved
2023-10-18 10:19:39 +00:00
}
}
}
}
} label: {
switch state {
case .unsaved: Image(systemName: "arrow.down.circle")
2023-11-01 17:58:44 +00:00
case .saving: ProgressView()
case .saved: Image(systemName: "checkmark.circle.fill")
2023-10-18 10:19:39 +00:00
}
}
} else {
EmptyView()
2023-10-18 10:19:39 +00:00
}
}
}
private enum SavingState {
case unsaved
case saving
case saved
}
private func imageData(_ url: URL) async -> Data? {
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
if data == nil {
data = try? await URLSession.shared.data(from: url).0
}
return data
}
private func uiimageFor(url: URL) async throws -> UIImage? {
let data = await imageData(url)
if let data {
return UIImage(data: data)
}
return nil
}
private func saveImage(url: URL) async -> Bool {
if let image = try? await uiimageFor(url: url) {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
return true
}
return false
}
}
private struct DisplayData: Identifiable, Hashable {
let id: String
let url: URL
let description: String?
let type: DisplayType
init?(from attachment: MediaAttachment) {
guard let url = attachment.url else { return nil }
guard let type = attachment.supportedType else { return nil }
2023-11-01 17:58:44 +00:00
id = attachment.id
self.url = url
2023-11-01 17:58:44 +00:00
description = attachment.description
self.type = DisplayType(from: type)
}
}
private struct DisplayView: View {
let data: DisplayData
var body: some View {
switch data.type {
case .image:
MediaUIAttachmentImageView(url: data.url)
case .av:
MediaUIAttachmentVideoView(viewModel: .init(url: data.url, forceAutoPlay: true))
}
}
2023-10-16 17:08:59 +00:00
}