feat: v1.1 P1 功能实现 + 体验优化(4 项新功能 + 9 项修复)

## 新功能

### 视频时长放宽(Feature 5)
- LivePhotoCore: targetDuration 从固定 0.917 秒改为动态计算(0.5-5.0 秒)
- LivePhotoCore: keyFrameTime 按比例映射到目标视频时长
- EditorView: slider 上限从 1.5 秒提升至 5.0 秒
- EditorView: 超过 2 秒显示壁纸兼容性提示,超过 3 秒显示分享截断提示

### 自定义封面帧(Feature 4)
- ExportParams: 新增 coverImageURL 字段
- EditorView: 封面帧区域增加 PhotosPicker 从相册导入照片作为封面
- AppState: coverImageURL 传递到 LivePhotoWorkflow
- EditorView: onDisappear 时清理临时封面文件,避免磁盘泄漏

### 收藏/模板预设(Feature 6)
- 新增 PresetManager.swift: EditingPreset 模型 + 预设管理器
- 支持保存/加载/删除预设,最多 10 个
- UserDefaults + iCloud KV Store 双重持久化
- EditorView: 预设保存/加载 UI + 空状态 ContentUnavailableView

### iCloud 同步(Feature 7)
- RecentWorksManager: 新增 NSUbiquitousKeyValueStore 同步
- PresetManager: 同步支持 iCloud KV Store
- 合并策略:按 ID 去重,保留最新记录,限制条数上限

## Bug 修复

### P0 级
- ResultView: Live Photo 预览比例从固定值改为动态计算 aspectRatio
- EditorView: 修复本地化字符串拼接问题(使用 String(localized:) 替代硬编码)

### P1 级
- EditorView: 预设列表空状态显示 ContentUnavailableView
- ProcessingView: 进度环与百分比文本动画同步(统一 overallProgress 计算)

### P2 级
- EditorView: 添加触觉反馈(比例切换、生成按钮、预设保存、封面导入)
- HomeView: 删除作品触觉反馈 + 入场动画优化
- ProcessingView: 脉冲动画背景尺寸微调(160-175pt)
- LaunchScreen: 品牌启动画面(App Icon + 标题 + 副标题)

## 本地化

- 新增约 25 个本地化 key,覆盖 8 种语言
  (zh-Hans, zh-Hant, en, ja, ko, es, fr, ar)
- 包含:预设管理、封面导入、时长提示、删除确认、
  空状态、诊断建议等全部新增 UI 文案

## 改动文件(16 个)

- Sources/LivePhotoCore/LivePhotoCore.swift — 动态时长 + coverImageURL
- to-live-photo/AppState.swift — coverImageURL 传递
- to-live-photo/PresetManager.swift — 新增预设管理器
- to-live-photo/RecentWorksManager.swift — iCloud 同步
- to-live-photo/DesignSystem.swift — 新增设计令牌
- to-live-photo/Localizable.xcstrings — 25+ 本地化 key
- to-live-photo/Views/EditorView.swift — 4 项新功能 UI
- to-live-photo/Views/HomeView.swift — 删除作品 + 触觉反馈
- to-live-photo/Views/ProcessingView.swift — 进度环动画修复
- to-live-photo/Views/ResultView.swift — 预览比例修复 + Live Photo 预览
- to-live-photo/Base.lproj/LaunchScreen.storyboard — 品牌启动画面
- to-live-photo/Assets.xcassets/Launch*.colorset — 启动画面颜色资源

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-08 00:04:10 +08:00
parent 846d8ea8d7
commit ec2e0a3ce5
16 changed files with 2021 additions and 71 deletions

View File

@@ -18,11 +18,17 @@ to-live-photo/to-live-photo/
## 构建命令
```bash
# 模拟器构建
xcodebuild -scheme to-live-photo -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
# 模拟器构建(必须指定 -project
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
-scheme to-live-photo \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
# Archive
xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/platform=iOS' -archivePath build/to-live-photo.xcarchive archive
xcodebuild -project to-live-photo/to-live-photo.xcodeproj \
-scheme to-live-photo \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/to-live-photo.xcarchive archive
```
## Git 规范
@@ -89,3 +95,10 @@ xcodebuild -scheme to-live-photo -configuration Release -destination 'generic/pl
- 新增/修改功能 → 同步 `USER_GUIDE.md` 相关章节
- 新增测试场景 → 同步 `TEST_MATRIX.md`
- 归档文档 → 不更新,保持历史原貌
## Skill 引用
本项目配套 skill: `live-photo-studio`(位于 `~/.claude/skills/live-photo-studio/SKILL.md`
包含:设计系统令牌速查、构建踩坑记录、导航架构、关键类型、代码规范等项目专属知识。
在本项目中进行 UI 开发、调试构建问题时,可调用 `/live-photo-studio` 获取上下文。

View File

@@ -134,6 +134,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
public var aspectRatio: AspectRatioTemplate
public var compatibilityMode: Bool
public var targetFrameRate: Int
public var coverImageURL: URL?
public var aiEnhanceConfig: AIEnhanceConfig
public init(
@@ -148,6 +149,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
aspectRatio: AspectRatioTemplate = .original,
compatibilityMode: Bool = false,
targetFrameRate: Int = 60,
coverImageURL: URL? = nil,
aiEnhanceConfig: AIEnhanceConfig = .disabled
) {
self.trimStart = trimStart
@@ -161,6 +163,7 @@ public struct ExportParams: Codable, Sendable, Hashable {
self.aspectRatio = aspectRatio
self.compatibilityMode = compatibilityMode
self.targetFrameRate = targetFrameRate
self.coverImageURL = coverImageURL
self.aiEnhanceConfig = aiEnhanceConfig
}
@@ -518,8 +521,9 @@ public actor LivePhotoBuilder {
destinationURL: trimmedURL
)
// 1 metadata.mov
let targetDuration = CMTimeMake(value: 550, timescale: 600) // ~0.917 live-wallpaper
// 5
let trimmedSeconds = min(max(exportParams.trimEnd - exportParams.trimStart, 0.5), 5.0)
let targetDuration = CMTime(seconds: trimmedSeconds, preferredTimescale: 600)
progress?(LivePhotoBuildProgress(stage: .normalize, fraction: 0.5))
let scaledVideoURL = try await scaleVideoToTargetDuration(
sourceURL: trimmedVideoURL,
@@ -531,8 +535,9 @@ public actor LivePhotoBuilder {
destinationURL: scaledURL
)
// 0.5 metadata.mov still-image-time
let relativeKeyFrameTime = 0.5 // 0.5 metadata.mov
//
let keyFrameRatio = (exportParams.keyFrameTime - exportParams.trimStart) / max(0.001, exportParams.trimEnd - exportParams.trimStart)
let relativeKeyFrameTime = max(0, min(trimmedSeconds, keyFrameRatio * trimmedSeconds))
progress?(LivePhotoBuildProgress(stage: .extractKeyFrame, fraction: 0))
let keyPhotoURL = try await resolveKeyPhotoURL(

View File

@@ -422,7 +422,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
@@ -457,7 +457,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;

View File

@@ -117,7 +117,7 @@ final class AppState {
let result = try await workflow.buildSaveValidate(
workId: workId,
sourceVideoURL: videoURL,
coverImageURL: nil,
coverImageURL: exportParams.coverImageURL,
exportParams: exportParams
) { progress in
Task { @MainActor in

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.945",
"green" : "0.400",
"red" : "0.388"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.953",
"green" : "0.941",
"red" : "0.941"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.180",
"green" : "0.102",
"red" : "0.102"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.482",
"green" : "0.420",
"red" : "0.420"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.698",
"green" : "0.627",
"red" : "0.627"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23484"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="AppIcon" translatesAutoresizingMaskIntoConstraints="NO" id="aIc-Kp-9xR">
<rect key="frame" x="136.5" y="233" width="120" height="120"/>
<constraints>
<constraint firstAttribute="width" constant="120" id="wCn-4s-Hx1"/>
<constraint firstAttribute="height" constant="120" id="hGt-7r-Qz2"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Live Photo Studio" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="74" y="406" width="245.66666666666666" height="29"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
<color key="textColor" name="LaunchAccent"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="视频一键转动态壁纸" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MHt-Pj-7nA">
<rect key="frame" x="123" y="443" width="148" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" name="LaunchSubtitle"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" name="LaunchBackground"/>
<constraints>
<constraint firstItem="aIc-Kp-9xR" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cXi-3p-Lm7"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="top" secondItem="aIc-Kp-9xR" secondAttribute="bottom" constant="24" id="tNr-8k-Vw3"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="5fK-s1-LcA"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" constant="72" id="8bR-Xa-2gP"/>
<constraint firstItem="MHt-Pj-7nA" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Kd4-2s-QnT"/>
<constraint firstItem="MHt-Pj-7nA" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" id="Rne-oP-87k"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="AppIcon" width="120" height="120"/>
<namedColor name="LaunchAccent">
<color red="0.38823529411764707" green="0.40000000000000002" blue="0.94509803921568625" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="LaunchBackground">
<color red="0.94117647058823528" green="0.94117647058823528" blue="0.95294117647058818" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="LaunchSubtitle">
<color red="0.41960784313725491" green="0.41960784313725491" blue="0.48235294117647054" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -554,7 +554,9 @@ struct SoftSlider: View {
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let progress = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
let effectiveRange = sanitizedRange(range)
let span = effectiveRange.upperBound - effectiveRange.lowerBound
let progress = span > 0 ? (value - effectiveRange.lowerBound) / span : 0
let thumbX = width * progress
ZStack(alignment: .leading) {
@@ -581,7 +583,7 @@ struct SoftSlider: View {
guard !isDisabled else { return }
let newProgress = gesture.location.x / width
let clampedProgress = max(0, min(1, newProgress))
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
value = effectiveRange.lowerBound + (effectiveRange.upperBound - effectiveRange.lowerBound) * clampedProgress
onEditingChanged?(true)
}
.onEnded { _ in
@@ -599,14 +601,23 @@ struct SoftSlider: View {
guard !isDisabled else { return }
switch direction {
case .increment:
value = min(range.upperBound, value + step)
value = min(sanitizedRange(range).upperBound, value + step)
case .decrement:
value = max(range.lowerBound, value - step)
value = max(sanitizedRange(range).lowerBound, value - step)
@unknown default:
break
}
}
}
private func sanitizedRange(_ input: ClosedRange<Double>) -> ClosedRange<Double> {
let lower = input.lowerBound.isFinite && !input.lowerBound.isNaN ? input.lowerBound : 0
let upper = input.upperBound.isFinite && !input.upperBound.isNaN ? input.upperBound : lower
if upper <= lower {
return lower...(lower + 0.0001)
}
return lower...upper
}
}
// MARK: -

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
//
// PresetManager.swift
// to-live-photo
//
//
//
import Combine
import Foundation
import LivePhotoCore
///
struct EditingPreset: Codable, Identifiable, Hashable {
let id: UUID
var name: String
let createdAt: Date
let aspectRatio: AspectRatioTemplate
let trimDuration: Double
let aiEnhance: Bool
let compatibilityMode: Bool
}
/// UserDefaults + iCloud 10
@MainActor
final class PresetManager: ObservableObject {
static let shared = PresetManager()
@Published private(set) var presets: [EditingPreset] = []
private let maxCount = 10
private let storageKey = "editing_presets_v1"
private let iCloudKey = "editing_presets_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default
private init() {
loadFromStorage()
setupICloudSync()
}
func savePreset(
name: String,
aspectRatio: AspectRatioTemplate,
trimDuration: Double,
aiEnhance: Bool,
compatibilityMode: Bool
) {
let preset = EditingPreset(
id: UUID(),
name: name,
createdAt: Date(),
aspectRatio: aspectRatio,
trimDuration: trimDuration,
aiEnhance: aiEnhance,
compatibilityMode: compatibilityMode
)
presets.insert(preset, at: 0)
if presets.count > maxCount {
presets = Array(presets.prefix(maxCount))
}
persistToStorage()
}
func removePreset(_ preset: EditingPreset) {
presets.removeAll { $0.id == preset.id }
persistToStorage()
}
// MARK: - iCloud
private func setupICloudSync() {
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: iCloudStore,
queue: .main
) { [weak self] notification in
guard let self else { return }
let userInfo = notification.userInfo
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
Task { @MainActor [weak self] in
self?.handleICloudChange(reason: reason, changedKeys: keys)
}
}
iCloudStore.synchronize()
}
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
guard let reason, let changedKeys,
changedKeys.contains(iCloudKey) else {
return
}
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud()
default:
break
}
}
private func mergeFromICloud() {
guard let data = iCloudStore.data(forKey: iCloudKey),
let remotePresets = try? JSONDecoder().decode([EditingPreset].self, from: data) else {
return
}
// id createdAt maxCount
var merged = Dictionary(grouping: presets + remotePresets) { $0.id }
.values
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
merged.sort { $0.createdAt > $1.createdAt }
presets = Array(merged.prefix(maxCount))
saveToLocalOnly()
}
// MARK: -
private func loadFromStorage() {
guard let data = UserDefaults.standard.data(forKey: storageKey) else {
return
}
do {
presets = try JSONDecoder().decode([EditingPreset].self, from: data)
} catch {
#if DEBUG
print("[PresetManager] Failed to decode: \(error)")
#endif
presets = []
}
}
private func persistToStorage() {
do {
let data = try JSONEncoder().encode(presets)
UserDefaults.standard.set(data, forKey: storageKey)
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
print("[PresetManager] Failed to encode: \(error)")
#endif
}
}
private func saveToLocalOnly() {
do {
let data = try JSONEncoder().encode(presets)
UserDefaults.standard.set(data, forKey: storageKey)
} catch {
#if DEBUG
print("[PresetManager] Failed to encode: \(error)")
#endif
}
}
}

View File

@@ -30,7 +30,7 @@ struct RecentWork: Codable, Identifiable, Hashable {
}
}
///
/// iCloud
@MainActor
final class RecentWorksManager: ObservableObject {
static let shared = RecentWorksManager()
@@ -39,9 +39,12 @@ final class RecentWorksManager: ObservableObject {
private let maxCount = 20 // 20
private let userDefaultsKey = "recent_works_v1"
private let iCloudKey = "recent_works_v1"
private let iCloudStore = NSUbiquitousKeyValueStore.default
private init() {
loadFromStorage()
setupICloudSync()
}
///
@@ -99,6 +102,56 @@ final class RecentWorksManager: ObservableObject {
}
}
// MARK: - iCloud
private func setupICloudSync() {
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: iCloudStore,
queue: .main
) { [weak self] notification in
guard let self else { return }
let userInfo = notification.userInfo
let reason = userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
let keys = userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
Task { @MainActor [weak self] in
self?.handleICloudChange(reason: reason, changedKeys: keys)
}
}
iCloudStore.synchronize()
}
private func handleICloudChange(reason: Int?, changedKeys: [String]?) {
guard let reason, let changedKeys,
changedKeys.contains(iCloudKey) else {
return
}
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
mergeFromICloud()
default:
break
}
}
private func mergeFromICloud() {
guard let data = iCloudStore.data(forKey: iCloudKey),
let remoteWorks = try? JSONDecoder().decode([RecentWork].self, from: data) else {
return
}
// createdAt assetLocalIdentifier 20
var merged = Dictionary(grouping: recentWorks + remoteWorks) { $0.assetLocalIdentifier }
.values
.compactMap { $0.max(by: { $0.createdAt < $1.createdAt }) }
merged.sort { $0.createdAt > $1.createdAt }
recentWorks = Array(merged.prefix(maxCount))
saveToLocalOnly()
}
// MARK: -
private func loadFromStorage() {
@@ -117,6 +170,18 @@ final class RecentWorksManager: ObservableObject {
}
private func saveToStorage() {
do {
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey)
iCloudStore.set(data, forKey: iCloudKey)
} catch {
#if DEBUG
print("[RecentWorksManager] Failed to encode: \(error)")
#endif
}
}
private func saveToLocalOnly() {
do {
let data = try JSONEncoder().encode(recentWorks)
UserDefaults.standard.set(data, forKey: userDefaultsKey)

View File

@@ -7,6 +7,7 @@
import SwiftUI
import AVKit
import PhotosUI
import LivePhotoCore
struct EditorView: View {
@@ -23,6 +24,8 @@ struct EditorView: View {
@State private var videoDuration: Double = 0
@State private var coverImage: UIImage?
@State private var isLoadingCover = false
@State private var coverExtractionTask: Task<Void, Never>?
@State private var coverExtractionToken = UUID()
//
@State private var selectedAspectRatio: AspectRatioTemplate = .fullScreen
@@ -46,8 +49,20 @@ struct EditorView: View {
//
@State private var videoDiagnosis: VideoDiagnosis?
//
@State private var customCoverItem: PhotosPickerItem?
@State private var customCoverImage: UIImage?
@State private var customCoverURL: URL?
//
@State private var showSavePresetAlert = false
@State private var presetName = ""
@State private var showPresetPicker = false
//
@State private var generateTapCount: Int = 0
@State private var presetSavedCount: Int = 0
@State private var coverImportCount: Int = 0
/// 使 iPad regular +
private var useIPadLayout: Bool {
@@ -69,10 +84,15 @@ struct EditorView: View {
}
.onDisappear {
player?.pause()
coverExtractionTask?.cancel()
coverExtractionTask = nil
cleanupCustomCoverFile()
}
.sensoryFeedback(.selection, trigger: selectedAspectRatio)
.sensoryFeedback(.impact(weight: .medium), trigger: generateTapCount)
.sensoryFeedback(.impact(weight: .light), trigger: lastCropScale)
.sensoryFeedback(.success, trigger: presetSavedCount)
.sensoryFeedback(.selection, trigger: coverImportCount)
}
// MARK: - iPhone
@@ -92,6 +112,7 @@ struct EditorView: View {
keyFrameSection
aiEnhanceSection
compatibilitySection
presetSection
generateButton
}
.padding(.horizontal, DesignTokens.Spacing.xl)
@@ -123,6 +144,7 @@ struct EditorView: View {
keyFrameSection
aiEnhanceSection
compatibilitySection
presetSection
generateButton
}
.padding(.vertical, DesignTokens.Spacing.lg)
@@ -241,8 +263,9 @@ struct EditorView: View {
}
HStack(spacing: DesignTokens.Spacing.md) {
if let coverImage {
Image(uiImage: coverImage)
let displayImage = customCoverImage ?? coverImage
if let displayImage {
Image(uiImage: displayImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 120)
@@ -264,12 +287,41 @@ struct EditorView: View {
Text(String(localized: "editor.coverFrameHint2"))
.font(.caption)
.foregroundColor(.textSecondary)
HStack(spacing: DesignTokens.Spacing.sm) {
PhotosPicker(
selection: $customCoverItem,
matching: .images
) {
Label(String(localized: "editor.coverFromAlbum"), systemImage: "photo.on.rectangle")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.controlSize(.small)
.accessibilityLabel(String(localized: "editor.coverFromAlbum"))
if customCoverImage != nil {
Button {
customCoverItem = nil
customCoverImage = nil
cleanupCustomCoverFile()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.accessibilityLabel(String(localized: "editor.coverReset"))
}
}
}
}
}
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.onChange(of: customCoverItem) { _, newItem in
loadCustomCover(from: newItem)
}
}
// MARK: -
@@ -290,20 +342,47 @@ struct EditorView: View {
SoftSlider(
value: $trimEnd,
in: 1.0...max(1.0, min(1.5, videoDuration)),
in: 1.0...max(1.0, min(5.0, videoDuration)),
step: 0.1,
gradient: Color.gradientPrimary,
accessibilityLabel: String(localized: "editor.videoDuration"),
isDisabled: videoDuration < 1.0,
onEditingChanged: { _ in
updateKeyFrameTime()
onEditingChanged: { editing in
updateKeyFrameTime(shouldExtractCover: !editing)
}
)
Text(String(localized: "editor.durationHint"))
.font(.caption)
.foregroundColor(.textSecondary)
//
if trimEnd - trimStart > 2 {
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(Color.accentOrange)
.font(.caption)
Text(String(localized: "editor.durationWallpaperWarning"))
.font(.caption)
.foregroundColor(.accentOrange)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
if trimEnd - trimStart > 3 {
HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(Color.accentOrange)
.font(.caption)
Text(String(localized: "editor.durationShareWarning"))
.font(.caption)
.foregroundColor(.accentOrange)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 2)
.animation(.easeInOut(duration: 0.3), value: trimEnd - trimStart > 3)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
@@ -558,6 +637,106 @@ struct EditorView: View {
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -
@ViewBuilder
private var presetSection: some View {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.md) {
HStack {
Image(systemName: "bookmark")
.foregroundStyle(.tint)
Text(String(localized: "editor.presetTitle"))
.font(.headline)
Spacer()
Button {
showPresetPicker = true
} label: {
Label(String(localized: "editor.presetLoad"), systemImage: "tray.and.arrow.down")
.font(.caption)
}
.disabled(PresetManager.shared.presets.isEmpty)
.accessibilityLabel(String(localized: "editor.presetLoad"))
Button {
presetName = ""
showSavePresetAlert = true
} label: {
Label(String(localized: "editor.presetSave"), systemImage: "tray.and.arrow.up")
.font(.caption)
}
.accessibilityLabel(String(localized: "editor.presetSave"))
}
}
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.alert(String(localized: "editor.presetSaveTitle"), isPresented: $showSavePresetAlert) {
TextField(String(localized: "editor.presetNamePlaceholder"), text: $presetName)
Button(String(localized: "editor.presetSaveConfirm")) {
saveCurrentPreset()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.sheet(isPresented: $showPresetPicker) {
presetPickerSheet
}
}
@ViewBuilder
private var presetPickerSheet: some View {
NavigationStack {
if PresetManager.shared.presets.isEmpty {
ContentUnavailableView(
String(localized: "editor.presetEmpty"),
systemImage: "bookmark",
description: Text(String(localized: "editor.presetEmptyHint"))
)
}
List {
ForEach(PresetManager.shared.presets) { preset in
Button {
applyPreset(preset)
showPresetPicker = false
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(preset.name)
.font(.body)
.foregroundStyle(.primary)
HStack(spacing: DesignTokens.Spacing.sm) {
Text(preset.aspectRatio.displayName)
Text("·")
Text(String(format: String(localized: "editor.durationSeconds"), preset.trimDuration))
if preset.aiEnhance {
Text("· AI")
}
if preset.compatibilityMode {
Text("· " + String(localized: "editor.compatibilityShort"))
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onDelete { indexSet in
for index in indexSet {
PresetManager.shared.removePreset(PresetManager.shared.presets[index])
}
}
}
.navigationTitle(String(localized: "editor.presetPickerTitle"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
showPresetPicker = false
}
}
}
}
.presentationDetents([.medium])
}
// MARK: -
@ViewBuilder
private var generateButton: some View {
@@ -578,7 +757,7 @@ struct EditorView: View {
Task {
do {
let durationCMTime = try await asset.load(.duration)
let durationSeconds = durationCMTime.seconds
let durationSeconds = sanitizedDuration(durationCMTime.seconds)
var diagnosis = VideoDiagnosis()
diagnosis.duration = durationSeconds
@@ -592,9 +771,10 @@ struct EditorView: View {
width: abs(transformedSize.width),
height: abs(transformedSize.height)
)
let safeSize = sanitizedVideoSize(absSize)
// 4K
let maxDim = max(absSize.width, absSize.height)
let maxDim = max(safeSize.width, safeSize.height)
diagnosis.isHighRes = maxDim > 3840
//
@@ -622,13 +802,13 @@ struct EditorView: View {
}
await MainActor.run {
videoNaturalSize = absSize
videoNaturalSize = safeSize
}
}
await MainActor.run {
videoDuration = durationSeconds
trimEnd = min(1.0, durationSeconds)
trimEnd = max(0.1, min(1.0, durationSeconds))
keyFrameTime = trimEnd / 2
player = AVPlayer(url: videoURL)
player?.play()
@@ -649,26 +829,46 @@ struct EditorView: View {
extractCoverFrame()
}
private func updateKeyFrameTime(shouldExtractCover: Bool) {
// keyFrameTime
keyFrameTime = max(trimStart, min(keyFrameTime, trimEnd))
if shouldExtractCover {
extractCoverFrame()
}
}
private func extractCoverFrame() {
//
coverExtractionTask?.cancel()
let token = UUID()
coverExtractionToken = token
isLoadingCover = true
let asset = AVURLAsset(url: videoURL)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
imageGenerator.maximumSize = CGSize(width: 200, height: 300)
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
let time = CMTime(seconds: keyFrameTime, preferredTimescale: 600)
let requestTime = keyFrameTime
coverExtractionTask = Task {
let asset = AVURLAsset(url: videoURL)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
imageGenerator.maximumSize = CGSize(width: 200, height: 300)
imageGenerator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 100)
imageGenerator.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 100)
let time = CMTime(seconds: requestTime, preferredTimescale: 600)
Task {
do {
let result = try await imageGenerator.image(at: time)
try Task.checkCancellation()
await MainActor.run {
guard coverExtractionToken == token else { return }
coverImage = UIImage(cgImage: result.image)
isLoadingCover = false
}
} catch is CancellationError {
// UI
} catch {
await MainActor.run {
guard coverExtractionToken == token else { return }
isLoadingCover = false
}
#if DEBUG
@@ -691,7 +891,8 @@ struct EditorView: View {
return .full
}
let videoRatio = videoNaturalSize.width / videoNaturalSize.height
let safeHeight = max(videoNaturalSize.height, 1)
let videoRatio = videoNaturalSize.width / safeHeight
var cropWidth: CGFloat = 1.0
var cropHeight: CGFloat = 1.0
@@ -714,6 +915,17 @@ struct EditorView: View {
return CropRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
}
private func sanitizedDuration(_ value: Double) -> Double {
guard value.isFinite, !value.isNaN else { return 0 }
return max(0, value)
}
private func sanitizedVideoSize(_ size: CGSize) -> CGSize {
let safeWidth = size.width.isFinite && !size.width.isNaN ? max(1, size.width) : 1080
let safeHeight = size.height.isFinite && !size.height.isNaN ? max(1, size.height) : 1920
return CGSize(width: safeWidth, height: safeHeight)
}
private func checkAndDownloadModel() {
guard aiEnhanceEnabled else { return }
@@ -756,6 +968,66 @@ struct EditorView: View {
}
}
private func saveCurrentPreset() {
let name = presetName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
PresetManager.shared.savePreset(
name: name,
aspectRatio: selectedAspectRatio,
trimDuration: trimEnd - trimStart,
aiEnhance: aiEnhanceEnabled,
compatibilityMode: compatibilityMode
)
presetSavedCount += 1
}
private func applyPreset(_ preset: EditingPreset) {
withAnimation {
selectedAspectRatio = preset.aspectRatio
trimEnd = min(preset.trimDuration, videoDuration)
trimStart = 0
aiEnhanceEnabled = preset.aiEnhance
compatibilityMode = preset.compatibilityMode
updateKeyFrameTime()
resetCropState()
}
}
private func loadCustomCover(from item: PhotosPickerItem?) {
guard let item else {
customCoverImage = nil
cleanupCustomCoverFile()
return
}
Task {
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else {
return
}
// LivePhotoCore 使
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent("custom_cover_\(UUID().uuidString).jpg")
if let jpegData = image.jpegData(compressionQuality: 0.95) {
try? jpegData.write(to: fileURL)
await MainActor.run {
cleanupCustomCoverFile()
customCoverImage = image
customCoverURL = fileURL
coverImportCount += 1
}
}
}
}
private func cleanupCustomCoverFile() {
if let url = customCoverURL {
try? FileManager.default.removeItem(at: url)
customCoverURL = nil
}
}
private func startProcessing() {
generateTapCount += 1
Analytics.shared.log(.editorGenerateClick, parameters: [
@@ -775,6 +1047,7 @@ struct EditorView: View {
keyFrameTime: keyFrameTime,
cropRect: cropRect,
aspectRatio: selectedAspectRatio,
coverImageURL: customCoverURL,
aiEnhanceConfig: aiEnhanceEnabled ? .standard : .disabled
)

View File

@@ -18,6 +18,8 @@ struct HomeView: View {
@State private var errorMessage: String?
@State private var showHero = false
@State private var showRecentWorks = false
@State private var showClearAllAlert = false
@State private var deleteWorkCount: Int = 0
var body: some View {
ScrollView(showsIndicators: false) {
@@ -65,6 +67,7 @@ struct HomeView: View {
showRecentWorks = true
}
}
.sensoryFeedback(.impact(weight: .light), trigger: deleteWorkCount)
}
// MARK: - Hero
@@ -207,6 +210,16 @@ struct HomeView: View {
Spacer()
//
Button {
showClearAllAlert = true
} label: {
Text(String(localized: "home.clearAll"))
.font(.footnote)
.foregroundColor(.accentPink)
}
.accessibilityLabel(String(localized: "home.clearAll"))
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
.font(.footnote)
.foregroundColor(.textMuted)
@@ -216,17 +229,32 @@ struct HomeView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DesignTokens.Spacing.lg) {
ForEach(recentWorks.recentWorks) { work in
RecentWorkCard(work: work) {
RecentWorkCard(work: work, onTap: {
appState.navigateTo(.wallpaperGuide(assetId: work.assetLocalIdentifier))
}
}, onDelete: {
recentWorks.removeWork(work)
deleteWorkCount += 1
})
}
}
.padding(.horizontal, DesignTokens.Spacing.xs)
.padding(.vertical, DesignTokens.Spacing.sm)
}
}
.alert(
String(localized: "home.clearAllConfirmTitle"),
isPresented: $showClearAllAlert
) {
Button(String(localized: "home.clearAll"), role: .destructive) {
recentWorks.clearAll()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(String(localized: "home.clearAllConfirmMessage"))
}
}
@MainActor
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
guard let item else { return }
@@ -245,7 +273,8 @@ struct HomeView: View {
Analytics.shared.log(.importVideoSuccess)
appState.navigateTo(.editor(videoURL: movie.url))
} catch {
errorMessage = String(localized: "home.loadError \(error.localizedDescription)")
let format = String(localized: "home.loadError")
errorMessage = String(format: format, error.localizedDescription)
isLoading = false
Analytics.shared.logError(.importVideoFail, error: error)
}
@@ -279,25 +308,36 @@ struct QuickStartStep: View {
}
}
// MARK: -
// MARK: -
struct VideoTransferable: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { video in
SentTransferredFile(video.url)
} importing: { received in
let tempDir = FileManager.default.temporaryDirectory
let filename = "import_\(UUID().uuidString).mov"
let destURL = tempDir.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.copyItem(at: received.file, to: destURL)
return VideoTransferable(url: destURL)
FileRepresentation(importedContentType: .movie) { received in
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
}
FileRepresentation(importedContentType: .mpeg4Movie) { received in
try copyToSandboxTemp(from: received.file, preferredExtension: "mp4")
}
FileRepresentation(importedContentType: .quickTimeMovie) { received in
try copyToSandboxTemp(from: received.file, preferredExtension: "mov")
}
}
private static func copyToSandboxTemp(from sourceURL: URL, preferredExtension: String) throws -> VideoTransferable {
let tempDir = FileManager.default.temporaryDirectory
let ext = sourceURL.pathExtension.isEmpty ? preferredExtension : sourceURL.pathExtension
let filename = "import_\(UUID().uuidString)." + ext
let destURL = tempDir.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destURL)
return VideoTransferable(url: destURL)
}
}
@@ -305,6 +345,7 @@ struct VideoTransferable: Transferable {
struct RecentWorkCard: View {
let work: RecentWork
let onTap: () -> Void
var onDelete: (() -> Void)?
@StateObject private var thumbnailLoader = ThumbnailLoader()
@State private var isPressed = false
@@ -364,9 +405,15 @@ struct RecentWorkCard: View {
.buttonStyle(.plain)
.scaleEffect(isPressed ? 0.97 : 1.0)
.animation(DesignTokens.Animation.spring, value: isPressed)
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
.contextMenu {
if let onDelete {
Button(role: .destructive) {
onDelete()
} label: {
Label(String(localized: "home.deleteWork"), systemImage: "trash")
}
}
}
.onAppear {
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
}

View File

@@ -93,11 +93,12 @@ struct ProcessingView: View {
//
SoftProgressRing(
progress: appState.processingProgress?.fraction ?? 0,
progress: overallProgress,
size: 140,
lineWidth: 10,
gradient: stageGradient
)
.animation(.easeInOut(duration: 0.5), value: overallProgress)
//
VStack(spacing: DesignTokens.Spacing.xs) {
@@ -106,11 +107,12 @@ struct ProcessingView: View {
.foregroundStyle(stageGradient)
.contentTransition(.symbolEffect(.replace))
if let progress = appState.processingProgress {
Text(String(format: "%.0f%%", progress.fraction * 100))
if appState.processingProgress != nil {
Text(String(format: "%.0f%%", overallProgress * 100))
.font(.headline.bold())
.foregroundColor(.textPrimary)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.5), value: overallProgress)
}
}
}
@@ -221,6 +223,13 @@ struct ProcessingView: View {
}
}
/// = ( + fraction) /
private var overallProgress: Double {
let totalStages = 7.0
let stageFraction = appState.processingProgress?.fraction ?? 0
return (Double(currentStageIndex) + stageFraction) / totalStages
}
private var stageIcon: String {
guard let stage = appState.processingProgress?.stage else {
return "hourglass"

View File

@@ -6,6 +6,7 @@
//
import SwiftUI
import PhotosUI
import LivePhotoCore
struct ResultView: View {
@@ -17,6 +18,7 @@ struct ResultView: View {
@State private var showContent = false
@State private var showButtons = false
@State private var celebrationParticles = false
@State private var livePhoto: PHLivePhoto?
var body: some View {
ZStack {
@@ -29,11 +31,15 @@ struct ResultView: View {
.accessibilityHidden(true)
}
VStack(spacing: DesignTokens.Spacing.xxxl) {
Spacer()
//
resultIcon
VStack(spacing: DesignTokens.Spacing.xl) {
if isSuccess && livePhoto != nil {
// Live Photo
livePhotoPreview
} else {
Spacer()
//
resultIcon
}
//
resultInfo
@@ -52,6 +58,38 @@ struct ResultView: View {
.onAppear {
animateIn()
}
.task {
guard isSuccess else { return }
await loadLivePhoto()
}
}
// MARK: - Live Photo
@ViewBuilder
private var livePhotoPreview: some View {
VStack(spacing: DesignTokens.Spacing.md) {
Spacer()
if let livePhoto {
let photoSize = livePhoto.size
let ratio = photoSize.width / max(photoSize.height, 1)
SoftCard(padding: DesignTokens.Spacing.md) {
LivePhotoPreviewView(livePhoto: livePhoto)
.aspectRatio(ratio, contentMode: .fit)
.frame(maxHeight: 360)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg))
.accessibilityLabel(String(localized: "result.livePhotoPreview.accessibilityLabel"))
}
.frame(maxWidth: .infinity)
}
Text(String(localized: "result.livePhotoPreview.hint"))
.font(.caption)
.foregroundColor(.textSecondary)
}
.opacity(showContent ? 1 : 0)
.offset(y: showContent ? 0 : 20)
.animation(.easeOut(duration: 0.4), value: showContent)
}
// MARK: -
@@ -187,6 +225,81 @@ struct ResultView: View {
showButtons = true
}
}
// MARK: - Live Photo
private func loadLivePhoto() async {
let imageURL = workflowResult.pairedImageURL
let videoURL = workflowResult.pairedVideoURL
guard FileManager.default.fileExists(atPath: imageURL.path),
FileManager.default.fileExists(atPath: videoURL.path) else {
return
}
let photo = await withCheckedContinuation { continuation in
PHLivePhoto.request(
withResourceFileURLs: [imageURL, videoURL],
placeholderImage: nil,
targetSize: .zero,
contentMode: .aspectFit
) { result, info in
let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) ?? false
if !isDegraded {
continuation.resume(returning: result)
}
}
}
if let photo {
withAnimation(.easeIn(duration: 0.3)) {
livePhoto = photo
}
}
}
}
// MARK: - Live Photo UIKit
struct LivePhotoPreviewView: UIViewRepresentable {
let livePhoto: PHLivePhoto
func makeUIView(context: Context) -> PHLivePhotoView {
let view = PHLivePhotoView()
view.contentMode = .scaleAspectFit
view.livePhoto = livePhoto
view.accessibilityLabel = String(localized: "result.livePhotoPreview.accessibilityLabel")
let longPress = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.handleLongPress(_:))
)
longPress.minimumPressDuration = 0.15
view.addGestureRecognizer(longPress)
return view
}
func updateUIView(_ uiView: PHLivePhotoView, context: Context) {
uiView.livePhoto = livePhoto
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
final class Coordinator: NSObject {
@objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard let livePhotoView = gesture.view as? PHLivePhotoView else { return }
switch gesture.state {
case .began:
livePhotoView.startPlayback(with: .full)
case .ended, .cancelled:
livePhotoView.stopPlayback()
default:
break
}
}
}
}
// MARK: -