mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2025-06-07 14:28:50 +00:00
Media viewer: various fixes
This commit is contained in:
parent
e9b322e289
commit
1b228d504f
5 changed files with 198 additions and 79 deletions
|
@ -63,6 +63,11 @@ struct IceCubesApp: App {
|
||||||
.environment(userPreferences)
|
.environment(userPreferences)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(item: $quickLook.url, content: { url in
|
||||||
|
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
|
||||||
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
.background(TransparentBackground())
|
||||||
|
})
|
||||||
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
pushNotificationsService.handledNotification = nil
|
pushNotificationsService.handledNotification = nil
|
||||||
|
|
|
@ -1,16 +1,95 @@
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
@preconcurrency import SwiftUI
|
||||||
import Models
|
import Models
|
||||||
|
import QuickLook
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable public class QuickLook {
|
@Observable public class QuickLook {
|
||||||
public var selectedMediaAttachment: MediaAttachment?
|
public var selectedMediaAttachment: MediaAttachment?
|
||||||
public var mediaAttachments: [MediaAttachment] = []
|
public var mediaAttachments: [MediaAttachment] = []
|
||||||
|
|
||||||
|
public var url: URL? {
|
||||||
|
didSet {
|
||||||
|
if url == nil {
|
||||||
|
cleanup(urls: urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public private(set) var urls: [URL] = []
|
||||||
|
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func prepareFor(selectedMediaAttachment: MediaAttachment?, mediaAttachments: [MediaAttachment]) {
|
public func prepareFor(selectedMediaAttachment: MediaAttachment, mediaAttachments: [MediaAttachment]) {
|
||||||
self.selectedMediaAttachment = selectedMediaAttachment
|
if ProcessInfo.processInfo.isiOSAppOnMac, let selectedURL = selectedMediaAttachment.url {
|
||||||
self.mediaAttachments = mediaAttachments
|
let urls = mediaAttachments.compactMap{ $0.url }
|
||||||
|
Task {
|
||||||
|
await prepareFor(urls: urls, selectedURL: selectedURL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.selectedMediaAttachment = selectedMediaAttachment
|
||||||
|
self.mediaAttachments = mediaAttachments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareFor(urls: [URL], selectedURL: URL) async {
|
||||||
|
var transaction = Transaction(animation: .default)
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
do {
|
||||||
|
var order = 0
|
||||||
|
let pathOrderMap = urls.reduce(into: [String: Int]()) { result, url in
|
||||||
|
result[url.lastPathComponent] = order
|
||||||
|
order += 1
|
||||||
|
}
|
||||||
|
let paths: [URL] = try await withThrowingTaskGroup(of: URL.self, body: { group in
|
||||||
|
var paths: [URL] = []
|
||||||
|
for url in urls {
|
||||||
|
group.addTask {
|
||||||
|
try await self.localPathFor(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for try await path in group {
|
||||||
|
paths.append(path)
|
||||||
|
}
|
||||||
|
return paths.sorted { url1, url2 in
|
||||||
|
pathOrderMap[url1.lastPathComponent] ?? 0 < pathOrderMap[url2.lastPathComponent] ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
withTransaction(transaction) {
|
||||||
|
self.urls = paths
|
||||||
|
url = paths.first(where: { $0.lastPathComponent == selectedURL.lastPathComponent })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
withTransaction(transaction) {
|
||||||
|
self.urls = []
|
||||||
|
url = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var quickLookDir: URL {
|
||||||
|
try! FileManager.default.url(for: .cachesDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: false)
|
||||||
|
.appending(component: "quicklook")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localPathFor(url: URL) async throws -> URL {
|
||||||
|
try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true)
|
||||||
|
let path = quickLookDir.appendingPathComponent(url.lastPathComponent)
|
||||||
|
|
||||||
|
// Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated
|
||||||
|
// context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor
|
||||||
|
// boundary.
|
||||||
|
// This is on the defaulted-to-nil second parameter of `.data(from:delegate:)`.
|
||||||
|
// There is a Radar tracking this & others like it.
|
||||||
|
let data = try await URLSession.shared.data(from: url).0
|
||||||
|
try data.write(to: path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanup(urls _: [URL]) {
|
||||||
|
try? FileManager.default.removeItem(at: quickLookDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import SwiftUI
|
||||||
|
|
||||||
func preparePlayer(autoPlay: Bool) {
|
func preparePlayer(autoPlay: Bool) {
|
||||||
player = .init(url: url)
|
player = .init(url: url)
|
||||||
player?.isMuted = true
|
player?.isMuted = !forceAutoPlay
|
||||||
player?.audiovisualBackgroundPlaybackPolicy = .pauses
|
player?.audiovisualBackgroundPlaybackPolicy = .pauses
|
||||||
if autoPlay || forceAutoPlay {
|
if autoPlay || forceAutoPlay {
|
||||||
player?.play()
|
player?.play()
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import CoreTransferable
|
||||||
|
|
||||||
|
struct MediaUIImageTransferable: Codable, Transferable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func fetchAsImage() async -> Image {
|
||||||
|
let data = try? await URLSession.shared.data(from: url).0
|
||||||
|
guard let data, let uiimage = UIImage(data: data) else {
|
||||||
|
return Image(systemName: "photo")
|
||||||
|
}
|
||||||
|
return Image(uiImage: uiimage)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var transferRepresentation: some TransferRepresentation {
|
||||||
|
ProxyRepresentation { media in
|
||||||
|
await media.fetchAsImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import SwiftUI
|
||||||
import Models
|
import Models
|
||||||
import QuickLook
|
import QuickLook
|
||||||
|
|
||||||
public struct MediaUIView: View {
|
public struct MediaUIView: View, @unchecked Sendable {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
public let selectedAttachment: MediaAttachment
|
public let selectedAttachment: MediaAttachment
|
||||||
|
@ -51,69 +51,7 @@ public struct MediaUIView: View {
|
||||||
.scrollTargetBehavior(.viewAligned)
|
.scrollTargetBehavior(.viewAligned)
|
||||||
.scrollPosition(id: $scrollToId)
|
.scrollPosition(id: $scrollToId)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
toolbarView
|
||||||
Button {
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if let url = attachments.first(where: { $0.id == scrollToId})?.url {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
quickLookURL = try? await localPathFor(url: url)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if let alt = attachments.first(where: { $0.id == scrollToId})?.description {
|
|
||||||
Button {
|
|
||||||
altTextDisplayed = alt
|
|
||||||
isAltAlertDisplayed = true
|
|
||||||
} label: {
|
|
||||||
Text("status.image.alt-text.abbreviation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if let attachment = attachments.first(where: { $0.id == scrollToId}),
|
|
||||||
let url = attachment.url,
|
|
||||||
attachment.supportedType == .image {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
isSavingPhoto = true
|
|
||||||
if await saveImage(url: url) {
|
|
||||||
withAnimation {
|
|
||||||
isSavingPhoto = false
|
|
||||||
didSavePhoto = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
didSavePhoto = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isSavingPhoto = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if isSavingPhoto {
|
|
||||||
ProgressView()
|
|
||||||
} else if didSavePhoto {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
} else {
|
|
||||||
Image(systemName: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if let url = attachments.first(where: { $0.id == scrollToId})?.url {
|
|
||||||
ShareLink(item: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.alert("status.editor.media.image-description",
|
.alert("status.editor.media.image-description",
|
||||||
isPresented: $isAltAlertDisplayed)
|
isPresented: $isAltAlertDisplayed)
|
||||||
|
@ -131,6 +69,80 @@ public struct MediaUIView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarView: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if let url = attachments.first(where: { $0.id == scrollToId})?.url {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
quickLookURL = await localPathFor(url: url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if let alt = attachments.first(where: { $0.id == scrollToId})?.description {
|
||||||
|
Button {
|
||||||
|
altTextDisplayed = alt
|
||||||
|
isAltAlertDisplayed = true
|
||||||
|
} label: {
|
||||||
|
Text("status.image.alt-text.abbreviation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if let attachment = attachments.first(where: { $0.id == scrollToId}),
|
||||||
|
let url = attachment.url,
|
||||||
|
attachment.supportedType == .image {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isSavingPhoto = true
|
||||||
|
if await saveImage(url: url) {
|
||||||
|
withAnimation {
|
||||||
|
isSavingPhoto = false
|
||||||
|
didSavePhoto = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
didSavePhoto = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isSavingPhoto = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isSavingPhoto {
|
||||||
|
ProgressView()
|
||||||
|
} else if didSavePhoto {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if let attachment = attachments.first(where: { $0.id == scrollToId}),
|
||||||
|
let url = attachment.url {
|
||||||
|
switch attachment.supportedType {
|
||||||
|
case .image:
|
||||||
|
let transferable = MediaUIImageTransferable(url: url)
|
||||||
|
ShareLink(item: transferable, preview: .init("status.media.contextmenu.share",
|
||||||
|
image: transferable))
|
||||||
|
default:
|
||||||
|
ShareLink(item: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var quickLookDir: URL {
|
private var quickLookDir: URL {
|
||||||
try! FileManager.default.url(for: .cachesDirectory,
|
try! FileManager.default.url(for: .cachesDirectory,
|
||||||
|
@ -140,23 +152,25 @@ public struct MediaUIView: View {
|
||||||
.appending(component: "quicklook")
|
.appending(component: "quicklook")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func localPathFor(url: URL) async throws -> URL {
|
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 localPathFor(url: URL) async -> URL {
|
||||||
try? FileManager.default.removeItem(at: quickLookDir)
|
try? FileManager.default.removeItem(at: quickLookDir)
|
||||||
try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true)
|
||||||
let path = quickLookDir.appendingPathComponent(url.lastPathComponent)
|
let path = quickLookDir.appendingPathComponent(url.lastPathComponent)
|
||||||
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
|
let data = await imageData(url)
|
||||||
if data == nil {
|
try? data?.write(to: path)
|
||||||
data = try await URLSession.shared.data(from: url).0
|
|
||||||
}
|
|
||||||
try data?.write(to: path)
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uiimageFor(url: URL) async throws -> UIImage? {
|
private func uiimageFor(url: URL) async throws -> UIImage? {
|
||||||
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
|
let data = await imageData(url)
|
||||||
if data == nil {
|
|
||||||
data = try await URLSession.shared.data(from: url).0
|
|
||||||
}
|
|
||||||
if let data {
|
if let data {
|
||||||
return UIImage(data: data)
|
return UIImage(data: data)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue