Files
Luma/Sources/Shared/UI/Components/MediaViewer.swift
T
wt 1e2670903f
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
added preview for views
2026-09-04 01:45:09 +07:00

412 lines
13 KiB
Swift

import AVFoundation
import AVKit
import SwiftUI
#if os(iOS)
import UIKit
private typealias MediaViewerPlatformImage = UIImage
#elseif os(macOS)
import AppKit
private typealias MediaViewerPlatformImage = NSImage
#endif
enum MediaViewerDismissGesturePolicy {
private static let verticalDominance: CGFloat = 1.15
static func isVertical(_ translation: CGSize) -> Bool {
let verticalDistance = abs(translation.height)
let horizontalDistance = abs(translation.width)
return verticalDistance >= 8 && verticalDistance > horizontalDistance * verticalDominance
}
static func shouldDismiss(
translation: CGSize,
predictedEndTranslation: CGSize,
containerHeight: CGFloat
) -> Bool {
guard isVertical(translation) else { return false }
let distanceThreshold = min(140, max(88, containerHeight * 0.14))
if abs(translation.height) >= distanceThreshold {
return true
}
return isVertical(predictedEndTranslation)
&& abs(predictedEndTranslation.height) >= distanceThreshold * 1.35
}
}
@MainActor
struct MediaViewer: View {
let item: MediaViewerItem
let onClose: () -> Void
@State private var dragOffset: CGFloat = 0
@State private var isTrackingDismissGesture = false
@State private var isCompletingDismissal = false
@State private var isPhotoZoomed = false
@State private var saveErrorMessage: String?
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black
.opacity(backgroundOpacity(in: geometry.size.height))
.ignoresSafeArea()
ZStack {
viewerContent
.ignoresSafeArea(edges: .bottom)
VStack(spacing: 0) {
Spacer()
HStack {
// Text(item.title)
// .font(.headline)
// .foregroundStyle(.white)
// .lineLimit(1)
Spacer()
Button(action: save) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.white.opacity(0.16), in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить")
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
.background(
LinearGradient(
colors: [.black.opacity(0.72), .clear],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
)
}
}
.offset(y: dragOffset)
.scaleEffect(contentScale(in: geometry.size.height))
}
.contentShape(Rectangle())
.simultaneousGesture(
verticalDismissGesture(in: geometry.size),
including: .all
)
.onChange(of: isPhotoZoomed) { _, isZoomed in
guard isZoomed else { return }
resetDismissGesture(animated: true)
}
.accessibilityAction(.escape) {
closeImmediately()
}
.alert("Ошибка сохранения", isPresented: saveErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
Text(saveErrorMessage ?? "Не удалось сохранить медиа.")
}
}
#if os(macOS)
.onExitCommand {
closeImmediately()
}
#endif
}
private var canTrackDismissGesture: Bool {
!isPhotoZoomed && !isCompletingDismissal
}
private func verticalDismissGesture(in size: CGSize) -> some Gesture {
DragGesture(minimumDistance: 8, coordinateSpace: .global)
.onChanged { value in
guard canTrackDismissGesture else { return }
if !isTrackingDismissGesture {
guard MediaViewerDismissGesturePolicy.isVertical(value.translation) else { return }
isTrackingDismissGesture = true
}
dragOffset = value.translation.height
}
.onEnded { value in
guard isTrackingDismissGesture else { return }
isTrackingDismissGesture = false
guard canTrackDismissGesture else {
resetDismissGesture(animated: true)
return
}
if MediaViewerDismissGesturePolicy.shouldDismiss(
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation,
containerHeight: size.height
) {
completeDismissal(
direction: dismissalDirection(for: value),
containerHeight: size.height
)
} else {
resetDismissGesture(animated: true)
}
}
}
private func dismissalDirection(for value: DragGesture.Value) -> CGFloat {
let projected = value.predictedEndTranslation.height
let distance = abs(projected) > abs(value.translation.height)
? projected
: value.translation.height
return distance < 0 ? -1 : 1
}
private func completeDismissal(direction: CGFloat, containerHeight: CGFloat) {
guard !isCompletingDismissal else { return }
isCompletingDismissal = true
withAnimation(.easeOut(duration: 0.18)) {
dragOffset = direction * max(700, containerHeight * 1.15)
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 150_000_000)
onClose()
}
}
private func resetDismissGesture(animated: Bool) {
isTrackingDismissGesture = false
if animated {
withAnimation(.spring(response: 0.3, dampingFraction: 0.84)) {
dragOffset = 0
}
} else {
dragOffset = 0
}
}
private func closeImmediately() {
guard !isCompletingDismissal else { return }
isCompletingDismissal = true
onClose()
}
private var saveErrorBinding: Binding<Bool> {
Binding(
get: { saveErrorMessage != nil },
set: { visible in if !visible { saveErrorMessage = nil } }
)
}
private func save() {
Task {
do {
try await MediaDownloadService.save(
url: item.url,
kind: item.kind,
filename: item.title
)
} catch {
saveErrorMessage = error.localizedDescription
}
}
}
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
}
private func backgroundOpacity(in containerHeight: CGFloat) -> Double {
Double(1 - dismissalProgress(in: containerHeight) * 0.72)
}
private func contentScale(in containerHeight: CGFloat) -> CGFloat {
1 - dismissalProgress(in: containerHeight) * 0.045
}
@ViewBuilder
private var viewerContent: some View {
switch item.kind {
case .photo:
ZoomablePhotoView(url: item.url, isZoomed: $isPhotoZoomed)
case .video:
FullWindowVideoPlayer(url: item.url)
default:
ContentUnavailableView(
"Не удалось открыть медиа",
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.white)
}
}
}
@MainActor
private struct FullWindowVideoPlayer: View {
let url: URL
@State private var player: AVPlayer?
init(url: URL) {
self.url = url
}
var body: some View {
VideoPlayer(player: player)
.task(id: url) {
player?.pause()
let nextPlayer = AVPlayer(url: url)
player = nextPlayer
nextPlayer.play()
}
.onDisappear {
player?.pause()
player = nil
}
}
}
@MainActor
private struct ZoomablePhotoView: View {
let url: URL
@Binding var isZoomed: Bool
@State private var imageValue: MediaViewerPlatformImage?
@State private var hasFinishedLoading = false
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
@GestureState private var gestureScale: CGFloat = 1
@GestureState private var gestureOffset: CGSize = .zero
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black
if let imageValue {
image(imageValue)
.scaledToFit()
.frame(width: geometry.size.width, height: geometry.size.height)
.scaleEffect(effectiveScale)
.offset(effectiveOffset)
.gesture(dragGesture)
.simultaneousGesture(magnifyGesture)
.onTapGesture(count: 2, perform: toggleZoom)
} else if hasFinishedLoading {
invalidImage
} else {
ProgressView()
.tint(.white)
.controlSize(.large)
}
}
.clipped()
}
.onChange(of: effectiveScale) { _, value in
isZoomed = value > 1.01
}
.onDisappear {
isZoomed = false
}
.task(id: url) {
imageValue = nil
hasFinishedLoading = false
let data = await Task.detached(priority: .userInitiated) {
try? Data(contentsOf: url, options: [.mappedIfSafe])
}.value
guard !Task.isCancelled else { return }
imageValue = data.flatMap { MediaViewerPlatformImage(data: $0) }
hasFinishedLoading = true
}
}
private var effectiveScale: CGFloat {
min(5, max(1, scale * gestureScale))
}
private var effectiveOffset: CGSize {
guard effectiveScale > 1 else { return .zero }
return CGSize(
width: offset.width + gestureOffset.width,
height: offset.height + gestureOffset.height
)
}
@ViewBuilder
private func image(_ value: MediaViewerPlatformImage) -> some View {
#if os(iOS)
Image(uiImage: value)
.resizable()
#elseif os(macOS)
Image(nsImage: value)
.resizable()
#endif
}
private var invalidImage: some View {
ContentUnavailableView(
"Фото повреждено",
systemImage: "photo.badge.exclamationmark"
)
.foregroundStyle(.white)
}
private var dragGesture: some Gesture {
DragGesture()
.updating($gestureOffset) { value, state, _ in
guard effectiveScale > 1 else { return }
state = value.translation
}
.onEnded { value in
guard effectiveScale > 1 else {
offset = .zero
return
}
offset.width += value.translation.width
offset.height += value.translation.height
}
}
private var magnifyGesture: some Gesture {
MagnifyGesture()
.updating($gestureScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
scale = min(5, max(1, scale * value.magnification))
if scale == 1 {
offset = .zero
}
}
}
private func toggleZoom() {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
if scale > 1 {
scale = 1
offset = .zero
} else {
scale = 2
}
}
}
}
#Preview {
MediaViewer(
item: MediaViewerItem(
id: "preview",
url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"),
kind: .photo,
title: "IMG_1234.jpg"
),
onClose: {}
)
}