mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2025-02-18 02:35:15 +00:00
New media viewer
This commit is contained in:
parent
017275ec69
commit
fd55020533
16 changed files with 361 additions and 155 deletions
|
@ -85,6 +85,7 @@
|
||||||
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
|
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
|
||||||
9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; };
|
9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; };
|
||||||
9FD542E72962D2FF0045321A /* Lists in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD542E62962D2FF0045321A /* Lists */; };
|
9FD542E72962D2FF0045321A /* Lists in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD542E62962D2FF0045321A /* Lists */; };
|
||||||
|
9FE0346C2ADD5C2100529EA8 /* MediaUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE0346B2ADD5C2100529EA8 /* MediaUI */; };
|
||||||
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; };
|
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; };
|
||||||
9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE3DB56296FEFCA00628CB0 /* AppAccount */; };
|
9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE3DB56296FEFCA00628CB0 /* AppAccount */; };
|
||||||
9FFF677C299B7B2C00FE700A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF677B299B7B2C00FE700A /* Notifications */; };
|
9FFF677C299B7B2C00FE700A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF677B299B7B2C00FE700A /* Notifications */; };
|
||||||
|
@ -222,6 +223,8 @@
|
||||||
9FCBB3D429859615009B77EE /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
9FCBB3D429859615009B77EE /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = "<group>"; };
|
9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = "<group>"; };
|
||||||
|
9FE034692ADD597100529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = "<group>"; };
|
||||||
|
9FE0346A2ADD59AC00529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = "<group>"; };
|
||||||
9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = "<group>"; };
|
9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = "<group>"; };
|
||||||
9FE3DB55296FEF5800628CB0 /* AppAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AppAccount; path = Packages/AppAccount; sourceTree = "<group>"; };
|
9FE3DB55296FEF5800628CB0 /* AppAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AppAccount; path = Packages/AppAccount; sourceTree = "<group>"; };
|
||||||
B0BAB49E29B3D7A9008F54D7 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
B0BAB49E29B3D7A9008F54D7 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||||
|
@ -287,6 +290,7 @@
|
||||||
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
|
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
|
||||||
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */,
|
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */,
|
||||||
9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */,
|
9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */,
|
||||||
|
9FE0346C2ADD5C2100529EA8 /* MediaUI in Frameworks */,
|
||||||
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
|
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
|
||||||
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
|
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
|
||||||
9FD542E72962D2FF0045321A /* Lists in Frameworks */,
|
9FD542E72962D2FF0045321A /* Lists in Frameworks */,
|
||||||
|
@ -434,6 +438,7 @@
|
||||||
9F55C68E295598F900F94077 /* Explore */,
|
9F55C68E295598F900F94077 /* Explore */,
|
||||||
9F5E581729545B5500A53960 /* Env */,
|
9F5E581729545B5500A53960 /* Env */,
|
||||||
9F398AA32935F90100A889F2 /* Models */,
|
9F398AA32935F90100A889F2 /* Models */,
|
||||||
|
9FE0346A2ADD59AC00529EA8 /* MediaUI */,
|
||||||
9F29553D292B67B600E0E81B /* Network */,
|
9F29553D292B67B600E0E81B /* Network */,
|
||||||
9FD542E52962D2CE0045321A /* Lists */,
|
9FD542E52962D2CE0045321A /* Lists */,
|
||||||
9F35DB4829506F7F00B3281A /* Notifications */,
|
9F35DB4829506F7F00B3281A /* Notifications */,
|
||||||
|
@ -470,6 +475,7 @@
|
||||||
9FBFE64C292A72BD00C250E9 /* Frameworks */ = {
|
9FBFE64C292A72BD00C250E9 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
9FE034692ADD597100529EA8 /* MediaUI */,
|
||||||
9F2A540D2969A0B0009B2D7C /* StoreKit.framework */,
|
9F2A540D2969A0B0009B2D7C /* StoreKit.framework */,
|
||||||
9F2A5404296995FB009B2D7C /* QuickLookUI.framework */,
|
9F2A5404296995FB009B2D7C /* QuickLookUI.framework */,
|
||||||
9F7335EE29674F7100AFF0BA /* QuickLook.framework */,
|
9F7335EE29674F7100AFF0BA /* QuickLook.framework */,
|
||||||
|
@ -612,6 +618,7 @@
|
||||||
9FE3DB56296FEFCA00628CB0 /* AppAccount */,
|
9FE3DB56296FEFCA00628CB0 /* AppAccount */,
|
||||||
065FA1FD29866CD600012EA0 /* LRUCache */,
|
065FA1FD29866CD600012EA0 /* LRUCache */,
|
||||||
DA0B24FA2A6876D50045BDD7 /* SFSafeSymbols */,
|
DA0B24FA2A6876D50045BDD7 /* SFSafeSymbols */,
|
||||||
|
9FE0346B2ADD5C2100529EA8 /* MediaUI */,
|
||||||
);
|
);
|
||||||
productName = IceCubesApp;
|
productName = IceCubesApp;
|
||||||
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
|
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
|
||||||
|
@ -1481,6 +1488,10 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Lists;
|
productName = Lists;
|
||||||
};
|
};
|
||||||
|
9FE0346B2ADD5C2100529EA8 /* MediaUI */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MediaUI;
|
||||||
|
};
|
||||||
9FE3DB56296FEFCA00628CB0 /* AppAccount */ = {
|
9FE3DB56296FEFCA00628CB0 /* AppAccount */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = AppAccount;
|
productName = AppAccount;
|
||||||
|
|
|
@ -9,6 +9,7 @@ import RevenueCat
|
||||||
import Status
|
import Status
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Timeline
|
import Timeline
|
||||||
|
import MediaUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct IceCubesApp: App {
|
struct IceCubesApp: App {
|
||||||
|
@ -54,11 +55,14 @@ struct IceCubesApp: App {
|
||||||
.environment(watcher)
|
.environment(watcher)
|
||||||
.environment(pushNotificationsService)
|
.environment(pushNotificationsService)
|
||||||
.environment(\.isSupporter, isSupporter)
|
.environment(\.isSupporter, isSupporter)
|
||||||
.fullScreenCover(item: $quickLook.url, content: { url in
|
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
|
||||||
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
|
MediaUIView(selectedAttachment: selectedMediaAttachment,
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
attachments: quickLook.mediaAttachments)
|
||||||
.background(TransparentBackground())
|
.presentationBackground(.ultraThinMaterial)
|
||||||
})
|
.presentationCornerRadius(16)
|
||||||
|
.environment(userPreferences)
|
||||||
|
.environment(theme)
|
||||||
|
}
|
||||||
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
pushNotificationsService.handledNotification = nil
|
pushNotificationsService.handledNotification = nil
|
||||||
|
@ -265,8 +269,6 @@ struct IceCubesApp: App {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil)
|
|
||||||
|
|
||||||
func application(_: UIApplication,
|
func application(_: UIApplication,
|
||||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||||
{
|
{
|
||||||
|
@ -300,11 +302,3 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
return configuration
|
return configuration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThemeObserverViewController: UIViewController {
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
||||||
super.traitCollectionDidChange(previousTraitCollection)
|
|
||||||
|
|
||||||
print(traitCollection.userInterfaceStyle.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -79,9 +79,8 @@ struct AccountDetailHeaderView: View {
|
||||||
guard account.haveHeader else {
|
guard account.haveHeader else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Task {
|
let attachement = MediaAttachment.imageWith(url: account.header)
|
||||||
await quickLook.prepareFor(urls: [account.header], selectedURL: account.header)
|
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityAddTraits([.isImage, .isButton])
|
.accessibilityAddTraits([.isImage, .isButton])
|
||||||
|
@ -110,9 +109,8 @@ struct AccountDetailHeaderView: View {
|
||||||
guard account.haveAvatar else {
|
guard account.haveAvatar else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Task {
|
let attachement = MediaAttachment.imageWith(url: account.avatar)
|
||||||
await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar)
|
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityAddTraits([.isImage, .isButton])
|
.accessibilityAddTraits([.isImage, .isButton])
|
||||||
|
|
|
@ -48,19 +48,12 @@ public struct AppAccountsSelectorView: View {
|
||||||
labelView
|
labelView
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $isPresented, content: {
|
.sheet(isPresented: $isPresented, content: {
|
||||||
if #available(iOS 16.4, *) {
|
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
.presentationBackground(.thinMaterial)
|
||||||
.presentationBackground(.thinMaterial)
|
.presentationCornerRadius(16)
|
||||||
.presentationCornerRadius(16)
|
.onAppear {
|
||||||
.onAppear {
|
refreshAccounts()
|
||||||
refreshAccounts()
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
|
||||||
.onAppear {
|
|
||||||
refreshAccounts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.onChange(of: currentAccount.account?.id) {
|
.onChange(of: currentAccount.account?.id) {
|
||||||
refreshAccounts()
|
refreshAccounts()
|
||||||
|
|
|
@ -200,11 +200,7 @@ struct ConversationMessageView: View {
|
||||||
.frame(height: 200)
|
.frame(height: 200)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let url = attachement.url {
|
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||||
Task {
|
|
||||||
await quickLook.prepareFor(urls: [url], selectedURL: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,87 +1,16 @@
|
||||||
import Combine
|
import Combine
|
||||||
@preconcurrency import QuickLook
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Models
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable public class QuickLook {
|
@Observable public class QuickLook {
|
||||||
public var url: URL? {
|
public var selectedMediaAttachment: MediaAttachment?
|
||||||
didSet {
|
public var mediaAttachments: [MediaAttachment] = []
|
||||||
if url == nil {
|
|
||||||
cleanup(urls: urls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public private(set) var urls: [URL] = []
|
|
||||||
public private(set) var isPreparing: Bool = false
|
|
||||||
public private(set) var latestError: Error?
|
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func prepareFor(urls: [URL], selectedURL: URL) async {
|
public func prepareFor(selectedMediaAttachment: MediaAttachment?, mediaAttachments: [MediaAttachment]) {
|
||||||
var transaction = Transaction(animation: .default)
|
self.selectedMediaAttachment = selectedMediaAttachment
|
||||||
transaction.disablesAnimations = true
|
self.mediaAttachments = mediaAttachments
|
||||||
withTransaction(transaction) {
|
|
||||||
isPreparing = 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 })
|
|
||||||
isPreparing = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
withTransaction(transaction) {
|
|
||||||
isPreparing = false
|
|
||||||
self.urls = []
|
|
||||||
url = nil
|
|
||||||
latestError = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
8
Packages/MediaUI/.gitignore
vendored
Normal file
8
Packages/MediaUI/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
32
Packages/MediaUI/Package.swift
Normal file
32
Packages/MediaUI/Package.swift
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
// swift-tools-version: 5.9
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MediaUI",
|
||||||
|
defaultLocalization: "en",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v17),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "MediaUI",
|
||||||
|
targets: ["MediaUI"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(name: "Models", path: "../Models"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "MediaUI",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Models", package: "Models"),
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.enableExperimentalFeature("StrictConcurrency"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,27 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Models
|
||||||
|
import NukeUI
|
||||||
|
|
||||||
|
struct MediaUIAttachmentImageView: View {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
@GestureState private var zoom = 1.0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MediaUIZoomableContainer {
|
||||||
|
LazyImage(url: url) { state in
|
||||||
|
if let image = state.image {
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.scaledToFit()
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.scaleEffect(zoom)
|
||||||
|
} else if state.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,11 +5,11 @@ import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable class VideoPlayerViewModel {
|
@Observable public class MediaUIAttachmentVideoViewModel {
|
||||||
var player: AVPlayer?
|
var player: AVPlayer?
|
||||||
private let url: URL
|
private let url: URL
|
||||||
|
|
||||||
init(url: URL) {
|
public init(url: URL) {
|
||||||
self.url = url
|
self.url = url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,15 +48,19 @@ import SwiftUI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VideoPlayerView: View {
|
public struct MediaUIAttachmentVideoView: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@Environment(\.isCompact) private var isCompact
|
@Environment(\.isCompact) private var isCompact
|
||||||
@Environment(UserPreferences.self) private var preferences
|
@Environment(UserPreferences.self) private var preferences
|
||||||
@Environment(Theme.self) private var theme
|
@Environment(Theme.self) private var theme
|
||||||
|
|
||||||
@State var viewModel: VideoPlayerViewModel
|
@State var viewModel: MediaUIAttachmentVideoViewModel
|
||||||
|
|
||||||
|
public init(viewModel: MediaUIAttachmentVideoViewModel) {
|
||||||
|
_viewModel = .init(wrappedValue: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
public var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VideoPlayer(player: viewModel.player)
|
VideoPlayer(player: viewModel.player)
|
||||||
.accessibilityAddTraits(.startsMediaSession)
|
.accessibilityAddTraits(.startsMediaSession)
|
121
Packages/MediaUI/Sources/MediaUI/MediaUIView.swift
Normal file
121
Packages/MediaUI/Sources/MediaUI/MediaUIView.swift
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import Foundation
|
||||||
|
import NukeUI
|
||||||
|
import Nuke
|
||||||
|
import SwiftUI
|
||||||
|
import Models
|
||||||
|
import QuickLook
|
||||||
|
|
||||||
|
public struct MediaUIView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
public let selectedAttachment: MediaAttachment
|
||||||
|
public let attachments: [MediaAttachment]
|
||||||
|
|
||||||
|
@State private var scrollToId: String?
|
||||||
|
@State private var altTextDisplayed: String?
|
||||||
|
@State private var isAltAlertDisplayed: Bool = false
|
||||||
|
@State private var quickLookURL: URL?
|
||||||
|
|
||||||
|
public init(selectedAttachment: MediaAttachment, attachments: [MediaAttachment]) {
|
||||||
|
self.selectedAttachment = selectedAttachment
|
||||||
|
self.attachments = attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack {
|
||||||
|
ForEach(attachments) { attachment in
|
||||||
|
if let url = attachment.url {
|
||||||
|
switch attachment.supportedType {
|
||||||
|
case .image:
|
||||||
|
MediaUIAttachmentImageView(url: url)
|
||||||
|
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0)
|
||||||
|
.id(attachment.id)
|
||||||
|
case .video, .gifv, .audio:
|
||||||
|
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
||||||
|
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0)
|
||||||
|
.containerRelativeFrame(.vertical, count: 1, span: 1, spacing: 0)
|
||||||
|
.id(attachment.id)
|
||||||
|
case .none:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollTargetLayout()
|
||||||
|
}
|
||||||
|
.scrollTargetBehavior(.viewAligned)
|
||||||
|
.scrollPosition(id: $scrollToId)
|
||||||
|
.toolbar {
|
||||||
|
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 = 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 url = attachments.first(where: { $0.id == scrollToId})?.url {
|
||||||
|
ShareLink(item: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("status.editor.media.image-description",
|
||||||
|
isPresented: $isAltAlertDisplayed)
|
||||||
|
{
|
||||||
|
Button("alert.button.ok", action: {})
|
||||||
|
} message: {
|
||||||
|
Text(altTextDisplayed ?? "")
|
||||||
|
}
|
||||||
|
.quickLookPreview($quickLookURL)
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
|
scrollToId = selectedAttachment.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.removeItem(at: quickLookDir)
|
||||||
|
try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true)
|
||||||
|
let path = quickLookDir.appendingPathComponent(url.lastPathComponent)
|
||||||
|
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
|
||||||
|
if data == nil {
|
||||||
|
data = try await URLSession.shared.data(from: url).0
|
||||||
|
}
|
||||||
|
try data?.write(to: path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
107
Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift
Normal file
107
Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// ref: https://stackoverflow.com/questions/74238414/is-there-an-easy-way-to-pinch-to-zoom-and-drag-any-view-in-swiftui
|
||||||
|
|
||||||
|
fileprivate let maxAllowedScale = 4.0
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct MediaUIZoomableContainer<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
@State private var currentScale: CGFloat = 1.0
|
||||||
|
@State private var tapLocation: CGPoint = .zero
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
func doubleTapAction(location: CGPoint) {
|
||||||
|
tapLocation = location
|
||||||
|
currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.onTapGesture(count: 2, perform: doubleTapAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ZoomableScrollView<ScollContent: View>: UIViewRepresentable {
|
||||||
|
private var content: ScollContent
|
||||||
|
@Binding private var currentScale: CGFloat
|
||||||
|
@Binding private var tapLocation: CGPoint
|
||||||
|
|
||||||
|
init(scale: Binding<CGFloat>, tapLocation: Binding<CGPoint>, @ViewBuilder content: () -> ScollContent) {
|
||||||
|
_currentScale = scale
|
||||||
|
_tapLocation = tapLocation
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIScrollView {
|
||||||
|
let scrollView = UIScrollView()
|
||||||
|
scrollView.backgroundColor = .clear
|
||||||
|
scrollView.delegate = context.coordinator
|
||||||
|
scrollView.maximumZoomScale = maxAllowedScale
|
||||||
|
scrollView.minimumZoomScale = 1
|
||||||
|
scrollView.bouncesZoom = true
|
||||||
|
scrollView.showsHorizontalScrollIndicator = false
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
scrollView.clipsToBounds = false
|
||||||
|
scrollView.backgroundColor = .clear
|
||||||
|
|
||||||
|
let hostedView = context.coordinator.hostingController.view!
|
||||||
|
hostedView.translatesAutoresizingMaskIntoConstraints = true
|
||||||
|
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
hostedView.frame = scrollView.bounds
|
||||||
|
hostedView.backgroundColor = .clear
|
||||||
|
scrollView.addSubview(hostedView)
|
||||||
|
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
return Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIScrollView, context: Context) {
|
||||||
|
context.coordinator.hostingController.rootView = content
|
||||||
|
|
||||||
|
if uiView.zoomScale > uiView.minimumZoomScale { // Scale out
|
||||||
|
uiView.setZoomScale(currentScale, animated: true)
|
||||||
|
} else if tapLocation != .zero { // Scale in to a specific point
|
||||||
|
uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true)
|
||||||
|
DispatchQueue.main.async { tapLocation = .zero }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
|
||||||
|
let scrollViewSize = scrollView.bounds.size
|
||||||
|
|
||||||
|
let width = scrollViewSize.width / scale
|
||||||
|
let height = scrollViewSize.height / scale
|
||||||
|
let x = center.x - (width / 2.0)
|
||||||
|
let y = center.y - (height / 2.0)
|
||||||
|
|
||||||
|
return CGRect(x: x, y: y, width: width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIScrollViewDelegate {
|
||||||
|
var hostingController: UIHostingController<ScollContent>
|
||||||
|
@Binding var currentScale: CGFloat
|
||||||
|
|
||||||
|
init(hostingController: UIHostingController<ScollContent>, scale: Binding<CGFloat>) {
|
||||||
|
self.hostingController = hostingController
|
||||||
|
_currentScale = scale
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
return hostingController.view
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||||
|
currentScale = scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,6 +44,15 @@ public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable {
|
||||||
public let previewUrl: URL?
|
public let previewUrl: URL?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
public let meta: MetaContainer?
|
public let meta: MetaContainer?
|
||||||
|
|
||||||
|
public static func imageWith(url: URL) -> MediaAttachment{
|
||||||
|
return .init(id: UUID().uuidString,
|
||||||
|
type: "image",
|
||||||
|
url: url,
|
||||||
|
previewUrl: url,
|
||||||
|
description: nil,
|
||||||
|
meta: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MediaAttachment: Sendable {}
|
extension MediaAttachment: Sendable {}
|
||||||
|
|
|
@ -18,6 +18,7 @@ let package = Package(
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(name: "AppAccount", path: "../AppAccount"),
|
.package(name: "AppAccount", path: "../AppAccount"),
|
||||||
.package(name: "Models", path: "../Models"),
|
.package(name: "Models", path: "../Models"),
|
||||||
|
.package(name: "MediaUI", path: "../MediaUI"),
|
||||||
.package(name: "Network", path: "../Network"),
|
.package(name: "Network", path: "../Network"),
|
||||||
.package(name: "Env", path: "../Env"),
|
.package(name: "Env", path: "../Env"),
|
||||||
.package(name: "DesignSystem", path: "../DesignSystem"),
|
.package(name: "DesignSystem", path: "../DesignSystem"),
|
||||||
|
@ -28,6 +29,7 @@ let package = Package(
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "AppAccount", package: "AppAccount"),
|
.product(name: "AppAccount", package: "AppAccount"),
|
||||||
.product(name: "Models", package: "Models"),
|
.product(name: "Models", package: "Models"),
|
||||||
|
.product(name: "MediaUI", package: "MediaUI"),
|
||||||
.product(name: "Network", package: "Network"),
|
.product(name: "Network", package: "Network"),
|
||||||
.product(name: "Env", package: "Env"),
|
.product(name: "Env", package: "Env"),
|
||||||
.product(name: "DesignSystem", package: "DesignSystem"),
|
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||||
|
|
|
@ -207,10 +207,8 @@ public struct StatusRowView: View {
|
||||||
if viewModel.finalStatus.mediaAttachments.isEmpty == false {
|
if viewModel.finalStatus.mediaAttachments.isEmpty == false {
|
||||||
Button("accessibility.status.media-viewer-action.label") {
|
Button("accessibility.status.media-viewer-action.label") {
|
||||||
HapticManager.shared.fireHaptic(of: .notification(.success))
|
HapticManager.shared.fireHaptic(of: .notification(.success))
|
||||||
Task {
|
let attachments = viewModel.finalStatus.mediaAttachments
|
||||||
let attachments = viewModel.finalStatus.mediaAttachments
|
quickLook.prepareFor(selectedMediaAttachment: attachments[0], mediaAttachments: attachments)
|
||||||
await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachments[0].url!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Models
|
||||||
import Nuke
|
import Nuke
|
||||||
import NukeUI
|
import NukeUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import MediaUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct StatusRowMediaPreviewView: View {
|
public struct StatusRowMediaPreviewView: View {
|
||||||
|
@ -87,9 +88,7 @@ public struct StatusRowMediaPreviewView: View {
|
||||||
if attachments.count == 1, let attachment = attachments.first {
|
if attachments.count == 1, let attachment = attachments.first {
|
||||||
makeFeaturedImagePreview(attachment: attachment)
|
makeFeaturedImagePreview(attachment: attachment)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task {
|
quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments)
|
||||||
await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachment.url!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
|
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
|
||||||
|
@ -117,11 +116,6 @@ public struct StatusRowMediaPreviewView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if quickLook.isPreparing {
|
|
||||||
quickLookLoadingView
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isHidingMedia {
|
if isHidingMedia {
|
||||||
sensitiveMediaOverlay
|
sensitiveMediaOverlay
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
@ -182,7 +176,7 @@ public struct StatusRowMediaPreviewView: View {
|
||||||
|
|
||||||
case .gifv, .video, .audio:
|
case .gifv, .video, .audio:
|
||||||
if let url = attachment.url {
|
if let url = attachment.url {
|
||||||
VideoPlayerView(viewModel: .init(url: url))
|
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
||||||
.frame(width: newSize.width, height: newSize.height)
|
.frame(width: newSize.width, height: newSize.height)
|
||||||
}
|
}
|
||||||
case .none:
|
case .none:
|
||||||
|
@ -265,7 +259,7 @@ public struct StatusRowMediaPreviewView: View {
|
||||||
}
|
}
|
||||||
case .gifv, .video, .audio:
|
case .gifv, .video, .audio:
|
||||||
if let url = attachment.url {
|
if let url = attachment.url {
|
||||||
VideoPlayerView(viewModel: .init(url: url))
|
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
||||||
.frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width)
|
.frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width)
|
||||||
.frame(height: imageMaxHeight)
|
.frame(height: imageMaxHeight)
|
||||||
.accessibilityAddTraits(.startsMediaSession)
|
.accessibilityAddTraits(.startsMediaSession)
|
||||||
|
@ -278,9 +272,7 @@ public struct StatusRowMediaPreviewView: View {
|
||||||
// #965: do not create overlapping tappable areas, when multiple images are shown
|
// #965: do not create overlapping tappable areas, when multiple images are shown
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task {
|
quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments)
|
||||||
await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachment.url!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
|
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
|
||||||
|
@ -288,21 +280,6 @@ public struct StatusRowMediaPreviewView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var quickLookLoadingView: some View {
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sensitiveMediaOverlay: some View {
|
private var sensitiveMediaOverlay: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
|
|
Loading…
Reference in a new issue