Compare commits

...

2 Commits

Author SHA1 Message Date
empty
683cebd5fa refactor: 删除未使用的 MakerNotes Patcher 死代码
删除文件:
- HEICMakerNotePatcher.swift (591 行)
- MakerNotesPatcher.swift (83 行)

原因:
1. 锁屏壁纸兼容性的根因是 MOV 的 still-image-time(必须为 0),
   而非 HEIC 的 MakerNotes 结构
2. 简化版 ContentIdentifier 方案经竞品验证,对 iOS 17+ 有效
3. 复杂的二进制 MakerNote 注入从未被需要

同时在 LivePhotoCore.swift 添加策略说明注释

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:16:37 +08:00
empty
e041cacd7d fix: UI 设计系统优化 - 无障碍、深色模式、对比度
- DesignSystem: 深色模式阴影适配,textMuted 对比度修复
- DesignSystem: SoftIconButton/SoftSlider/SoftProgressRing 添加 accessibilityLabel
- EditorView: AspectRatioButton 添加无障碍支持,清理硬编码颜色
- WallpaperGuideView: 清理硬编码颜色 (Color.secondary → Color.softElevated)
- Localizable: 修复 home.worksCount 插值 key 格式

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:15:41 +08:00
10 changed files with 243 additions and 734 deletions

View File

@@ -1,17 +1,39 @@
# Live Photo 无法设置为动态壁纸:根因记录
# Live Photo 动态壁纸兼容性:根因与解决方案
## 现象
- 生成的 Live Photo 在相册中可识别、可播放,但在用作壁纸时提示动态不可用
- 生成的 Live Photo 在相册中可识别、可播放,但在"用作壁纸"时提示"动态不可用"
## 关键发现(本地文件元数据)
## 根因分析
### 问题代码(初始版本 299415a
- `Sources/LivePhotoCore/LivePhotoCore.swift``metadataItemForStillImageTime()``com.apple.quicktime.still-image-time` 的 value 写成 **-1**Int8
- 代码注释错误地认为"竞品使用 0xFF (-1)"——实际上竞品live-wallpaper使用的是 **0**
### 关键发现
- `/Users/yuanjiantsui/Downloads/paired.mov` 中的 timed metadata`StillImageTime`**-1**int8
- `exiftool` 输出示例:`[Track3] StillImageTime : -1`
- `exiftool` 输出示例:`[Track3] StillImageTime : -1`
## 代码根因
- `Sources/LivePhotoCore/LivePhotoCore.swift:842``LivePhotoBuilder.metadataItemForStillImageTime()``com.apple.quicktime.still-image-time` 的 value 写成 `-1`
- 建议改为 `0`int8
- 仍用 timed metadata group 的 `timeRange.start` 表达关键帧时间。
## 解决方案(来自 live-wallpaper 项目)
## 额外建议(兼容性
- 移除非标准的 mdta keys`Sample Time` / `Sample Duration`(当前写入到 `assetWriter.metadata`)。
- 若仍不兼容,优先尝试 H.264、30fps、SDR、2~3 秒时长作为壁纸兼容模式。
### 修复方式(提交 a8b334e
1. **复制 metadata.mov 文件**:从 live-wallpaper 项目复制 `metadata.mov`MD5: 9c3a827031283513b28844514dbe44d5
2. **采用 metadata track 复制策略**:不再手动创建 still-image-time 元数据,而是从预制的 metadata.mov 复制整个 metadata track
### live-wallpaper 项目的正确实现
- `LivePhotoCreator.swift:52`: `"com.apple.quicktime.still-image-time": 0` // **值为 0不是 -1**
- `LivePhotoCreator.swift:110`: `createMetadataItem(identifier: "com.apple.quicktime.still-image-time", value: 0)`
- `Converter4Video.swift:88-187`: 使用 AVAssetReaderTrackOutput + AVAssetWriterInput 复制 metadata track
### 两个项目的关键对齐点
| 特性 | live-wallpaper | to-live-photo修复后|
|-----|---------------|----------------------|
| metadata.mov | ✅ 有 | ✅ 有同一文件MD5 相同)|
| still-image-time 值 | 0 | 从 metadata.mov 复制(隐式为 0|
| 分辨率策略 | 固定 1080×1920 | 可配置,默认 maxDimension=1920 |
| 视频时长 | CMTimeMake(550, 600) ≈ 0.917s | 同 |
## 技术要点总结
1. **StillImageTime 必须为 0**-1 会导致壁纸设置时"动态不可用"
2. **metadata track 复制优于手动创建**:预制的 metadata.mov 包含完整的元数据结构,比手动构建更可靠
3. **分辨率不是壁纸兼容性的决定因素**:核心问题是元数据格式

View File

@@ -1,591 +0,0 @@
import Foundation
/// HEIC MakerNotes
/// CGImageDestination Int64 MakerNotes
public enum HEICMakerNoteError: Error, CustomStringConvertible {
case invalidHEIC(String)
case metaNotFound
case iinfNotFound
case ilocNotFound
case exifItemNotFound
case exifLocationNotFound(itemID: UInt32)
case exifPayloadTooSmall
case tiffNotFound
case invalidTIFF(String)
case exifIFDPointerNotFound
case makerNoteTagNotFound
case makerNoteOutOfRange
case makerNoteTooShort(available: Int, required: Int)
public var description: String {
switch self {
case .invalidHEIC(let msg): return "Invalid HEIC: \(msg)"
case .metaNotFound: return "meta box not found"
case .iinfNotFound: return "iinf box not found"
case .ilocNotFound: return "iloc box not found"
case .exifItemNotFound: return "Exif item not found"
case .exifLocationNotFound(let id): return "Exif item location not found for item_ID=\(id)"
case .exifPayloadTooSmall: return "Exif payload too small"
case .tiffNotFound: return "TIFF header not found"
case .invalidTIFF(let msg): return "Invalid TIFF: \(msg)"
case .exifIFDPointerNotFound: return "ExifIFDPointer (0x8769) not found"
case .makerNoteTagNotFound: return "MakerNote tag (0x927C) not found"
case .makerNoteOutOfRange: return "MakerNote data out of range"
case .makerNoteTooShort(let available, let required):
return "MakerNote too short: available=\(available), required=\(required)"
}
}
}
public final class HEICMakerNotePatcher {
// MARK: - Public API
/// MakerNotes HEIC
/// Exif item MakerNote
public static func injectMakerNoteInPlace(fileURL: URL, makerNote: Data) throws {
var fileData = try Data(contentsOf: fileURL, options: [.mappedIfSafe])
//
let metaRange = try findTopLevelBox(type: "meta", in: fileData)
guard let metaRange else { throw HEICMakerNoteError.metaNotFound }
let meta = BoxView(data: fileData, range: metaRange)
let metaChildrenStart = meta.contentStart + 4
guard metaChildrenStart <= meta.end else {
throw HEICMakerNoteError.invalidHEIC("meta content too short")
}
guard let iinfRange = try findChildBox(type: "iinf", within: metaChildrenStart..<meta.end, in: fileData) else {
throw HEICMakerNoteError.iinfNotFound
}
guard let ilocRange = try findChildBox(type: "iloc", within: metaChildrenStart..<meta.end, in: fileData) else {
throw HEICMakerNoteError.ilocNotFound
}
let exifItemID = try parseIINFAndFindExifItemID(data: fileData, iinfRange: iinfRange)
let (locations, ilocInfo) = try parseILOCWithInfo(data: fileData, ilocRange: ilocRange)
guard let exifLoc = locations[exifItemID] else {
throw HEICMakerNoteError.exifLocationNotFound(itemID: exifItemID)
}
let exifStart = Int(exifLoc.offset)
let exifLen = Int(exifLoc.length)
guard exifStart >= 0, exifLen > 0, exifStart + exifLen <= fileData.count else {
throw HEICMakerNoteError.exifPayloadTooSmall
}
// Exif item
let existingExif = fileData.subdata(in: exifStart..<(exifStart + exifLen))
// Exif item MakerNote
let newExif = try buildNewExifItem(existingExif: existingExif, newMakerNote: makerNote)
if newExif.count <= exifLen {
// Exif
var paddedExif = newExif
if paddedExif.count < exifLen {
paddedExif.append(Data(repeating: 0x00, count: exifLen - paddedExif.count))
}
fileData.replaceSubrange(exifStart..<(exifStart + exifLen), with: paddedExif)
} else {
// Exif iloc
let newExifOffset = fileData.count
fileData.append(newExif)
// iloc offset length
try updateILOC(
in: &fileData,
ilocRange: ilocRange,
ilocInfo: ilocInfo,
itemID: exifItemID,
newOffset: UInt64(newExifOffset),
newLength: UInt64(newExif.count)
)
}
try fileData.write(to: fileURL, options: [.atomic])
}
// MARK: - Build New Exif Item
/// Exif item MakerNote
private static func buildNewExifItem(existingExif: Data, newMakerNote: Data) throws -> Data {
guard existingExif.count >= 10 else {
throw HEICMakerNoteError.exifPayloadTooSmall
}
// Exif item
// 4 bytes: TIFF header offset ( 6 "Exif\0\0" )
// 4 bytes: "Exif"
// 2 bytes: \0\0
// TIFF
let tiffOffsetValue = existingExif.readUInt32BE(at: 0)
let tiffStart = 4 + Int(tiffOffsetValue)
guard tiffStart + 8 <= existingExif.count else {
throw HEICMakerNoteError.tiffNotFound
}
//
let endianMarker = existingExif.subdata(in: tiffStart..<(tiffStart + 2))
let isBigEndian: Bool
if endianMarker == Data([0x4D, 0x4D]) {
isBigEndian = true
} else if endianMarker == Data([0x49, 0x49]) {
isBigEndian = false
} else {
throw HEICMakerNoteError.invalidTIFF("Invalid endian marker")
}
// TIFF Big-Endian Apple
var newTiff = Data()
// TIFF Header: "MM" + 0x002A + IFD0 offset (8)
newTiff.append(contentsOf: [0x4D, 0x4D]) // Big-endian
newTiff.append(contentsOf: [0x00, 0x2A]) // TIFF magic
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x08]) // IFD0 offset = 8
// IFD0: 1 entry (ExifIFDPointer)
// Entry count: 1
newTiff.append(contentsOf: [0x00, 0x01])
// Entry: ExifIFDPointer (0x8769)
let exifIFDOffset: UInt32 = 8 + 2 + 12 + 4 // = 26 (IFD0 )
newTiff.append(contentsOf: [0x87, 0x69]) // tag
newTiff.append(contentsOf: [0x00, 0x04]) // type = LONG
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) // count = 1
newTiff.appendUInt32BE(exifIFDOffset) // value = offset to Exif IFD
// Next IFD offset: 0 (no more IFDs)
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x00])
// Exif IFD: 1 entry (MakerNote)
let makerNoteDataOffset: UInt32 = exifIFDOffset + 2 + 12 + 4 // = 44
newTiff.append(contentsOf: [0x00, 0x01]) // entry count
// Entry: MakerNote (0x927C)
newTiff.append(contentsOf: [0x92, 0x7C]) // tag
newTiff.append(contentsOf: [0x00, 0x07]) // type = UNDEFINED
newTiff.appendUInt32BE(UInt32(newMakerNote.count)) // count
newTiff.appendUInt32BE(makerNoteDataOffset) // offset to MakerNote data
// Next IFD offset: 0
newTiff.append(contentsOf: [0x00, 0x00, 0x00, 0x00])
// MakerNote data
newTiff.append(newMakerNote)
// Exif item
var newExifItem = Data()
// 4 bytes: offset to TIFF (= 6, "Exif\0\0")
newExifItem.append(contentsOf: [0x00, 0x00, 0x00, 0x06])
// "Exif\0\0"
newExifItem.append(contentsOf: [0x45, 0x78, 0x69, 0x66, 0x00, 0x00])
// TIFF data
newExifItem.append(newTiff)
return newExifItem
}
// MARK: - Box Parsing
private struct BoxHeader {
let type: String
let size: Int
let headerSize: Int
let contentStart: Int
let end: Int
}
private struct BoxView {
let data: Data
let range: Range<Int>
var start: Int { range.lowerBound }
var end: Int { range.upperBound }
var header: BoxHeader {
let size32 = Int(data.readUInt32BE(at: start))
let type = data.readFourCC(at: start + 4)
if size32 == 1 {
let size64 = Int(data.readUInt64BE(at: start + 8))
return BoxHeader(
type: type,
size: size64,
headerSize: 16,
contentStart: start + 16,
end: start + size64
)
} else if size32 == 0 {
return BoxHeader(
type: type,
size: data.count - start,
headerSize: 8,
contentStart: start + 8,
end: data.count
)
} else {
return BoxHeader(
type: type,
size: size32,
headerSize: 8,
contentStart: start + 8,
end: start + size32
)
}
}
var contentStart: Int { header.contentStart }
}
private static func findTopLevelBox(type: String, in data: Data) throws -> Range<Int>? {
var cursor = 0
while cursor + 8 <= data.count {
let box = try readBoxHeader(at: cursor, data: data)
if box.type == type { return cursor..<box.end }
cursor = box.end
}
return nil
}
private static func findChildBox(type: String, within range: Range<Int>, in data: Data) throws -> Range<Int>? {
var cursor = range.lowerBound
while cursor + 8 <= range.upperBound {
let box = try readBoxHeader(at: cursor, data: data)
if box.type == type { return cursor..<min(box.end, range.upperBound) }
cursor = box.end
}
return nil
}
private static func readBoxHeader(at offset: Int, data: Data) throws -> BoxHeader {
guard offset + 8 <= data.count else {
throw HEICMakerNoteError.invalidHEIC("box header out of bounds")
}
let size32 = Int(data.readUInt32BE(at: offset))
let type = data.readFourCC(at: offset + 4)
if size32 == 1 {
guard offset + 16 <= data.count else {
throw HEICMakerNoteError.invalidHEIC("large size box header out of bounds")
}
let size64 = Int(data.readUInt64BE(at: offset + 8))
guard size64 >= 16 else {
throw HEICMakerNoteError.invalidHEIC("invalid box size")
}
return BoxHeader(type: type, size: size64, headerSize: 16, contentStart: offset + 16, end: offset + size64)
} else if size32 == 0 {
return BoxHeader(type: type, size: data.count - offset, headerSize: 8, contentStart: offset + 8, end: data.count)
} else {
guard size32 >= 8 else {
throw HEICMakerNoteError.invalidHEIC("invalid box size")
}
return BoxHeader(type: type, size: size32, headerSize: 8, contentStart: offset + 8, end: offset + size32)
}
}
// MARK: - iinf / infe Parsing
private static func parseIINFAndFindExifItemID(data: Data, iinfRange: Range<Int>) throws -> UInt32 {
let iinf = BoxView(data: data, range: iinfRange).header
var cursor = iinf.contentStart
guard cursor + 4 <= iinf.end else {
throw HEICMakerNoteError.invalidHEIC("iinf too short")
}
let version = data.readUInt8(at: cursor)
cursor += 4
let entryCount: UInt32
if version == 0 {
entryCount = UInt32(data.readUInt16BE(at: cursor))
cursor += 2
} else {
entryCount = data.readUInt32BE(at: cursor)
cursor += 4
}
var foundExif: UInt32?
var scanned: UInt32 = 0
while cursor + 8 <= iinf.end, scanned < entryCount {
let infe = try readBoxHeader(at: cursor, data: data)
guard infe.type == "infe" else {
cursor = infe.end
continue
}
var p = infe.contentStart
guard p + 4 <= infe.end else {
throw HEICMakerNoteError.invalidHEIC("infe too short")
}
let infeVersion = data.readUInt8(at: p)
p += 4
let itemID: UInt32
if infeVersion >= 3 {
itemID = data.readUInt32BE(at: p); p += 4
} else {
itemID = UInt32(data.readUInt16BE(at: p)); p += 2
}
p += 2 // item_protection_index
var itemType = ""
if infeVersion >= 2 {
guard p + 4 <= infe.end else {
throw HEICMakerNoteError.invalidHEIC("infe item_type out of bounds")
}
itemType = data.readFourCC(at: p)
p += 4
}
if itemType == "Exif" {
foundExif = itemID
break
}
cursor = infe.end
scanned += 1
}
guard let exifID = foundExif else {
throw HEICMakerNoteError.exifItemNotFound
}
return exifID
}
// MARK: - iloc Parsing
private struct ItemLocation {
let offset: UInt64
let length: UInt64
}
private struct ILOCInfo {
let version: UInt8
let offsetSize: Int
let lengthSize: Int
let baseOffsetSize: Int
let indexSize: Int
let itemEntries: [UInt32: ILOCItemEntry]
}
private struct ILOCItemEntry {
let itemID: UInt32
let extentOffsetPosition: Int // extent_offset
let extentLengthPosition: Int // extent_length
}
private static func parseILOCWithInfo(data: Data, ilocRange: Range<Int>) throws -> ([UInt32: ItemLocation], ILOCInfo) {
let iloc = BoxView(data: data, range: ilocRange).header
var cursor = iloc.contentStart
guard cursor + 4 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc too short")
}
let version = data.readUInt8(at: cursor)
cursor += 4
guard cursor + 2 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc header out of bounds")
}
let offsetSize = Int(data.readUInt8(at: cursor) >> 4)
let lengthSize = Int(data.readUInt8(at: cursor) & 0x0F)
cursor += 1
let baseOffsetSize = Int(data.readUInt8(at: cursor) >> 4)
let indexSize = Int(data.readUInt8(at: cursor) & 0x0F)
cursor += 1
let itemCount: UInt32
if version < 2 {
guard cursor + 2 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc item_count out of bounds")
}
itemCount = UInt32(data.readUInt16BE(at: cursor))
cursor += 2
} else {
guard cursor + 4 <= iloc.end else {
throw HEICMakerNoteError.invalidHEIC("iloc item_count out of bounds")
}
itemCount = data.readUInt32BE(at: cursor)
cursor += 4
}
var locations: [UInt32: ItemLocation] = [:]
var itemEntries: [UInt32: ILOCItemEntry] = [:]
for _ in 0..<itemCount {
guard cursor + 2 <= iloc.end else { break }
let itemID: UInt32
if version < 2 {
itemID = UInt32(data.readUInt16BE(at: cursor)); cursor += 2
} else {
guard cursor + 4 <= iloc.end else { break }
itemID = data.readUInt32BE(at: cursor); cursor += 4
}
if version == 1 || version == 2 {
guard cursor + 2 <= iloc.end else { break }
cursor += 2
}
guard cursor + 2 <= iloc.end else { break }
cursor += 2
guard cursor + baseOffsetSize <= iloc.end else { break }
let baseOffset = try data.readUIntBE(at: cursor, size: baseOffsetSize)
cursor += baseOffsetSize
guard cursor + 2 <= iloc.end else { break }
let extentCount = Int(data.readUInt16BE(at: cursor))
cursor += 2
var firstExtentOffset: UInt64 = 0
var firstExtentLength: UInt64 = 0
var extentOffsetPos = 0
var extentLengthPos = 0
for e in 0..<extentCount {
if (version == 1 || version == 2) && indexSize > 0 {
guard cursor + indexSize <= iloc.end else { break }
cursor += indexSize
}
guard cursor + offsetSize + lengthSize <= iloc.end else { break }
if e == 0 {
extentOffsetPos = cursor
}
let extentOffset = try data.readUIntBE(at: cursor, size: offsetSize)
cursor += offsetSize
if e == 0 {
extentLengthPos = cursor
}
let extentLength = try data.readUIntBE(at: cursor, size: lengthSize)
cursor += lengthSize
if e == 0 {
firstExtentOffset = extentOffset
firstExtentLength = extentLength
}
}
let fileOffset = baseOffset + firstExtentOffset
if firstExtentLength > 0 {
locations[itemID] = ItemLocation(offset: fileOffset, length: firstExtentLength)
itemEntries[itemID] = ILOCItemEntry(
itemID: itemID,
extentOffsetPosition: extentOffsetPos,
extentLengthPosition: extentLengthPos
)
}
}
let info = ILOCInfo(
version: version,
offsetSize: offsetSize,
lengthSize: lengthSize,
baseOffsetSize: baseOffsetSize,
indexSize: indexSize,
itemEntries: itemEntries
)
return (locations, info)
}
private static func updateILOC(
in fileData: inout Data,
ilocRange: Range<Int>,
ilocInfo: ILOCInfo,
itemID: UInt32,
newOffset: UInt64,
newLength: UInt64
) throws {
guard let entry = ilocInfo.itemEntries[itemID] else {
throw HEICMakerNoteError.exifLocationNotFound(itemID: itemID)
}
// offset
fileData.writeUIntBE(at: entry.extentOffsetPosition, value: newOffset, size: ilocInfo.offsetSize)
// length
fileData.writeUIntBE(at: entry.extentLengthPosition, value: newLength, size: ilocInfo.lengthSize)
}
// MARK: - EXIF/TIFF Patching
enum Endian {
case little
case big
}
}
// MARK: - Data Extensions
private extension Data {
func readUInt8(at offset: Int) -> UInt8 {
self[self.index(self.startIndex, offsetBy: offset)]
}
func readUInt16BE(at offset: Int) -> UInt16 {
let b0 = UInt16(readUInt8(at: offset))
let b1 = UInt16(readUInt8(at: offset + 1))
return (b0 << 8) | b1
}
func readUInt32BE(at offset: Int) -> UInt32 {
let b0 = UInt32(readUInt8(at: offset))
let b1 = UInt32(readUInt8(at: offset + 1))
let b2 = UInt32(readUInt8(at: offset + 2))
let b3 = UInt32(readUInt8(at: offset + 3))
return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
}
func readUInt64BE(at offset: Int) -> UInt64 {
var v: UInt64 = 0
for i in 0..<8 {
v = (v << 8) | UInt64(readUInt8(at: offset + i))
}
return v
}
func readFourCC(at offset: Int) -> String {
let bytes = self.subdata(in: offset..<(offset + 4))
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
func readUIntBE(at offset: Int, size: Int) throws -> UInt64 {
if size == 0 { return 0 }
guard offset + size <= count else {
throw HEICMakerNoteError.invalidHEIC("Variable-length integer out of bounds")
}
var v: UInt64 = 0
for i in 0..<size {
v = (v << 8) | UInt64(readUInt8(at: offset + i))
}
return v
}
mutating func appendUInt32BE(_ value: UInt32) {
append(UInt8((value >> 24) & 0xFF))
append(UInt8((value >> 16) & 0xFF))
append(UInt8((value >> 8) & 0xFF))
append(UInt8(value & 0xFF))
}
mutating func writeUIntBE(at offset: Int, value: UInt64, size: Int) {
for i in 0..<size {
let byteIndex = self.index(self.startIndex, offsetBy: offset + i)
let shift = (size - 1 - i) * 8
self[byteIndex] = UInt8((value >> shift) & 0xFF)
}
}
}

View File

@@ -850,8 +850,17 @@ public actor LivePhotoBuilder {
exifDict[kCGImagePropertyExifPixelYDimension as String] = height
imageProperties[kCGImagePropertyExifDictionary as String] = exifDict
// ContentIdentifier MakerNotes
// 使 ContentIdentifier Photos Live Photo
// HEIC Metadata 2024.01
// =========================================
// ContentIdentifierkey "17" MakerNotes
//
//
// 1. MOV still-image-time 0
// HEIC MakerNotes livephoto_wallpaper_root_cause_still_image_time
// 2. iOS 17+ Live Photo ContentIdentifier
// 3. live-wallpaper使 ContentIdentifier
//
// HEICMakerNotePatcher
let assetIdentifierKey = "17" // Content Identifier
var makerAppleDict: [String: Any] = [:]

View File

@@ -1,83 +0,0 @@
import Foundation
/// HEIC Apple MakerNotes LivePhotoVideoIndex
/// CGImageDestination Int64 MakerNotes
/// 使
public struct MakerNotesPatcher {
// MARK: - iPhone MakerNotes
/// ContentIdentifier MakerNotes 36 ASCII UUID + null
private static let contentIdentifierOffset = 0x580 // 1408
private static let contentIdentifierLength = 36
/// LivePhotoVideoIndex MakerNotes 8 Big-Endian Int64
private static let livePhotoVideoIndexOffset = 0x5a6 // 1446
private static let livePhotoVideoIndexLength = 8
/// iPhone MakerNotes iPhone 13 Pro Max Live Photo
/// Apple MakerNotes ContentIdentifier LivePhotoVideoIndex
private static let makerNotesTemplate: Data = {
// Base64 MakerNotes
let base64 = """
QXBwbGUgaU9TAAABTU0APQABAAkAAAABAAAAEAACAAcAAAIAAAAC8AADAAcAAABoAAAE8AAEAAkA\
AAABAAAAAQAFAAkAAAABAAAAqQAGAAkAAAABAAAApQAHAAkAAAABAAAAAQAIAAoAAAADAAAFWAAM\
AAoAAAACAAAFcAANAAkAAAABAAAAFwAOAAkAAAABAAAABAAQAAkAAAABAAAAAQARAAIAAAAlAAAF\
gAAUAAkAAAABAAAACgAXABAAAAABAAAFpgAZAAkAAAABAAAAAgAaAAIAAAAGAAAFrgAfAAkAAAAB\
AAAAAAAgAAIAAAAlAAAFtAAhAAoAAAABAAAF2gAjAAkAAAACAAAF4gAlABAAAAABAAAF6gAmAAkA\
AAABAAAAAwAnAAoAAAABAAAF8gAoAAkAAAABAAAAAQArAAIAAAAlAAAF+gAtAAkAAAABAAATXAAu\
AAkAAAABAAAAAQAvAAkAAAABAAAAMAAwAAoAAAABAAAGIAAzAAkAAAABAAAQAAA0AAkAAAABAAAA\
BAA1AAkAAAABAAAAAwA2AAkAAAABAADnJAA3AAkAAAABAAAABAA4AAkAAAABAAACPgA5AAkAAAAB\
AAAAAAA6AAkAAAABAAAAAAA7AAkAAAABAAAAAAA8AAkAAAABAAAABAA9AAkAAAABAAAAAAA/AAkA\
AAABAAAAOwBAAAcAAABQAAAGKABBAAkAAAABAAAAAABCAAkAAAABAAAAAABDAAkAAAABAAAAAABE\
AAkAAAABAAAAAABFAAkAAAABAAAAAABGAAkAAAABAAAAAABIAAkAAAABAAACPgBJAAkAAAABAAAA\
AABKAAkAAAABAAAAAgBNAAoAAAABAAAGeABOAAcAAAB5AAAGgABPAAcAAAArAAAG+gBSAAkAAAAB\
AAAAAQBTAAkAAAABAAAAAQBVAAkAAAABAAAAAQBYAAkAAAABAAAHAwBgAAkAAAABAAASAABhAAkA\
AAABAAAAGgAAAAC9AtMCxAKWAlsCIALqAbwBkgFrAUYBJQEIAfEA3QDMAAMDMwNAA/QCmQJWAg0C\
1QGoAX4BUwEtAQ8B9QDhAM0ALANqA2oDTAPrAnUCKALpAb4BhgFWATABEAH0AN4AyQA9A7oDwgNP\
A7gCXAIYAugBqwF2AUwBJgEGAeoA0gC+ANECrwPSAxIDZAIVAt4BqwF8AVABKQEHAekA0AC6AKgA\
zwFCAnYCBAK/AZYBcwFQAS8BDQHvANQAvQCpAJgAigDUAP8AGgEVARoB5QC6AMUAsACsAJkAggBy\
AGQAXABTAGAAaABqAGYAXABMAEcARwBCAEEAPgAvACQAJAAlACoARgBIAEcARAA/ADcAMwAvACoA\
KAAnACEAHQAfAB8AIAAxADIAMQAwAC4AKwAnACcAJQAfABwAGgAXABkAEwAUACkAKgAmACQAIgAg\
ACEAIgAgAB4AHAAZABgAFAAXABUAIgAcABoAGwAaABcAHAAdABkAGAAYABgAFwAaABgAGAAaABsA\
GgAYABUAEwAWABcAEwAWABcAFQAVABYAEwAUABoAFwAWABMAEgATABAADwATABMAEgANAA8ADwAP\
AAwAEwAVABIAEwANABUAEwATABIADAAPAAsAEAASAA8ADgARABEADwAMAAwADwAWABMAEgASABQA\
DQAPAAoADAAOAGJwbGlzdDAw1AECAwQFBgcIVWZsYWdzVXZhbHVlWXRpbWVzY2FsZVVlcG9jaBAB\
EwABVEUBR7fxEjuaygAQAAgRFx0nLS84PQAAAAAAAAEBAAAAAAAAAAkAAAAAAAAAAAAAAAAAAAA/\
///J3gAANk3//8R4AAe+////5bUAAVy+AAAAOwAAAQAAAAAnAAABAAAAAAAAAAAAAAAAAAAAALtA\
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABI\
RUlDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbwBwAGwAaQBzAHQAMAAwANQBAgMEBQYHCFEzUTFR\
MlEwEAQiAAAAACQ/AAAAEAEIERMVFxkbICUAAAAAAAABAQAAAAAAAAAJAAAAAAAAAAAAAAAAAAAA\
JwAC8/UAABV+YnBsaXN0MDDSAQIDSFExUTIQA6IFCtIGBwgJUzIuMVMyLjIjQEsf2IAAAACJQAAA\
AAAAAADSBgcLDCM/4hqAAAAAAKNAVMAAAAAAAAgNDxETFhsfIywlOkMAAAAAAAABAQAAAAAAAAAL\
AAAAAAAAAAAAAAAAAAAAQQAAAAA=
"""
return Data(base64Encoded: base64.replacingOccurrences(of: "\\\n", with: "").replacingOccurrences(of: "\n", with: ""))!
}()
/// MakerNotes
/// - Parameters:
/// - contentIdentifier: Live Photo Content Identifier (UUID )
/// - livePhotoVideoIndex: Live Photo Video Index ( Float32 bitPattern)
/// - Returns: MakerNotes
public static func createMakerNotes(
contentIdentifier: String,
livePhotoVideoIndex: Int64
) -> Data {
var data = makerNotesTemplate
// ContentIdentifier
let uuidData = contentIdentifier.data(using: .ascii)!
let paddedUUID = uuidData + Data(repeating: 0, count: max(0, contentIdentifierLength - uuidData.count))
data.replaceSubrange(contentIdentifierOffset..<(contentIdentifierOffset + contentIdentifierLength), with: paddedUUID.prefix(contentIdentifierLength))
// LivePhotoVideoIndex (Big-Endian)
var bigEndianValue = UInt64(bitPattern: livePhotoVideoIndex).bigEndian
let indexData = Data(bytes: &bigEndianValue, count: livePhotoVideoIndexLength)
data.replaceSubrange(livePhotoVideoIndexOffset..<(livePhotoVideoIndexOffset + livePhotoVideoIndexLength), with: indexData)
return data
}
}

View File

@@ -41,6 +41,18 @@ enum DesignTokens {
static let xxxl: CGFloat = 32
static let display: CGFloat = 40
}
// MARK:
enum Animation {
///
static let spring = SwiftUI.Animation.spring(response: 0.3, dampingFraction: 0.6)
///
static let quick = SwiftUI.Animation.easeInOut(duration: 0.15)
///
static let standard = SwiftUI.Animation.easeInOut(duration: 0.25)
///
static let smooth = SwiftUI.Animation.easeInOut(duration: 0.5)
}
}
// MARK: -
@@ -59,7 +71,8 @@ extension Color {
// MARK:
static let textPrimary = Color(light: Color(hex: "#2D2D3A"), dark: Color(hex: "#E4E4EB"))
static let textSecondary = Color(light: Color(hex: "#6B6B7B"), dark: Color(hex: "#A0A0B2"))
static let textMuted = Color(light: Color(hex: "#9999A9"), dark: Color(hex: "#6B6B7B"))
// textMuted WCAG AA (4.5:1)
static let textMuted = Color(light: Color(hex: "#777788"), dark: Color(hex: "#8888A0"))
// MARK:
static let accentPurple = Color(hex: "#6366F1")
@@ -137,42 +150,71 @@ extension Color {
// MARK: - Soft UI
struct SoftShadow: ViewModifier {
let isPressed: Bool
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.shadow(
color: Color.black.opacity(isPressed ? 0.15 : 0.08),
color: shadowDark,
radius: isPressed ? 4 : 8,
x: isPressed ? 2 : 4,
y: isPressed ? 2 : 4
)
.shadow(
color: Color.white.opacity(isPressed ? 0.5 : 0.7),
color: shadowLight,
radius: isPressed ? 4 : 8,
x: isPressed ? -2 : -4,
y: isPressed ? -2 : -4
)
}
// 使
private var shadowDark: Color {
colorScheme == .dark
? Color.black.opacity(isPressed ? 0.4 : 0.3)
: Color.black.opacity(isPressed ? 0.15 : 0.08)
}
// 使
private var shadowLight: Color {
colorScheme == .dark
? Color.white.opacity(isPressed ? 0.03 : 0.05)
: Color.white.opacity(isPressed ? 0.5 : 0.7)
}
}
struct SoftInnerShadow: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.overlay(
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
.stroke(innerShadowDark, lineWidth: 1)
.blur(radius: 2)
.offset(x: 1, y: 1)
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.black, .clear], startPoint: .topLeading, endPoint: .bottomTrailing)))
)
.overlay(
RoundedRectangle(cornerRadius: DesignTokens.Radius.lg)
.stroke(Color.white.opacity(0.5), lineWidth: 1)
.stroke(innerShadowLight, lineWidth: 1)
.blur(radius: 2)
.offset(x: -1, y: -1)
.mask(RoundedRectangle(cornerRadius: DesignTokens.Radius.lg).fill(LinearGradient(colors: [.clear, .black], startPoint: .topLeading, endPoint: .bottomTrailing)))
)
}
private var innerShadowDark: Color {
colorScheme == .dark
? Color.black.opacity(0.3)
: Color.black.opacity(0.06)
}
private var innerShadowLight: Color {
colorScheme == .dark
? Color.white.opacity(0.08)
: Color.white.opacity(0.5)
}
}
extension View {
@@ -315,11 +357,13 @@ struct SoftSecondaryButton: View {
struct SoftIconButton: View {
let icon: String
let isActive: Bool
let accessibilityLabel: String?
let action: () -> Void
init(_ icon: String, isActive: Bool = false, action: @escaping () -> Void) {
init(_ icon: String, isActive: Bool = false, accessibilityLabel: String? = nil, action: @escaping () -> Void) {
self.icon = icon
self.isActive = isActive
self.accessibilityLabel = accessibilityLabel
self.action = action
}
@@ -347,6 +391,9 @@ struct SoftIconButton: View {
)
}
.buttonStyle(ScaleButtonStyle())
.accessibilityLabel(accessibilityLabel ?? icon)
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(isActive ? .isSelected : [])
}
}
@@ -356,17 +403,20 @@ struct SoftProgressRing: View {
let size: CGFloat
let lineWidth: CGFloat
let gradient: LinearGradient
let accessibilityLabel: String?
init(
progress: Double,
size: CGFloat = 120,
lineWidth: CGFloat = 8,
gradient: LinearGradient = Color.gradientPrimary
gradient: LinearGradient = Color.gradientPrimary,
accessibilityLabel: String? = nil
) {
self.progress = progress
self.size = size
self.lineWidth = lineWidth
self.gradient = gradient
self.accessibilityLabel = accessibilityLabel
}
var body: some View {
@@ -394,8 +444,11 @@ struct SoftProgressRing: View {
.stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.frame(width: size - 16, height: size - 16)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: progress)
.animation(DesignTokens.Animation.smooth, value: progress)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel ?? String(localized: "进度"))
.accessibilityValue(Text("\(Int(progress * 100))%"))
}
}
@@ -475,15 +528,21 @@ struct SoftSlider: View {
@Binding var value: Double
let range: ClosedRange<Double>
let gradient: LinearGradient
let accessibilityLabel: String
let step: Double
init(
value: Binding<Double>,
in range: ClosedRange<Double>,
gradient: LinearGradient = Color.gradientPrimary
step: Double = 0.1,
gradient: LinearGradient = Color.gradientPrimary,
accessibilityLabel: String = ""
) {
self._value = value
self.range = range
self.step = step
self.gradient = gradient
self.accessibilityLabel = accessibilityLabel
}
var body: some View {
@@ -521,6 +580,19 @@ struct SoftSlider: View {
}
}
.frame(height: 28)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
.accessibilityValue(Text(String(format: "%.1f", value)))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
value = min(range.upperBound, value + step)
case .decrement:
value = max(range.lowerBound, value - step)
@unknown default:
break
}
}
}
}

View File

@@ -254,7 +254,7 @@
}
}
},
"home.worksCount" : {
"home.worksCount %lld" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
@@ -1874,6 +1874,78 @@
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "确定要清空最近作品记录吗?这不会删除相册中的 Live Photo。" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "確定要清除最近作品記錄嗎?這不會刪除相簿中的 Live Photo。" } }
}
},
"已选中" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Selected" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已选中" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "已選中" } }
}
},
"进度" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Progress" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "进度" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "進度" } }
}
},
"滑块" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Slider" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "滑块" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "滑桿" } }
}
},
"accessibility.settings" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Settings" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "设置" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "設定" } }
}
},
"accessibility.play" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Play" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "播放" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "播放" } }
}
},
"accessibility.pause" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Pause" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "暂停" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "暫停" } }
}
},
"accessibility.livePhoto" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Live Photo" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Live Photo 作品" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "Live Photo 作品" } }
}
},
"accessibility.aspectRatio" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Aspect ratio %@" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "画面比例 %@" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "畫面比例 %@" } }
}
},
"accessibility.duration" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Duration" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "时长" } },
"zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "時長" } }
}
}
},
"version" : "1.0"

View File

@@ -86,8 +86,8 @@ struct EditorView: View {
compatibilitySection
generateButton
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.padding(.horizontal, DesignTokens.Spacing.xl)
.padding(.vertical, DesignTokens.Spacing.lg)
}
}
@@ -117,11 +117,11 @@ struct EditorView: View {
compatibilitySection
generateButton
}
.padding(.vertical, 16)
.padding(.vertical, DesignTokens.Spacing.lg)
}
.frame(maxWidth: 360)
}
.padding(24)
.padding(DesignTokens.Spacing.xxl)
}
// MARK: - iPad
@@ -239,10 +239,10 @@ struct EditorView: View {
Text("选择适合壁纸的比例,锁屏推荐使用「锁屏」或「全屏」")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
.padding(16)
.background(Color.secondary.opacity(0.1))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@@ -271,26 +271,26 @@ struct EditorView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.2))
.fill(Color.softPressed)
.frame(width: 80, height: 120)
.overlay {
Image(systemName: "photo")
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("此图片将作为 Live Photo 的静态封面")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
Text("拖动下方滑杆选择封面时刻")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
}
}
.padding(16)
.background(Color.secondary.opacity(0.1))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@@ -317,10 +317,10 @@ struct EditorView: View {
Text("Live Photo 壁纸推荐时长1 ~ 1.5 秒")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
.padding(16)
.background(Color.secondary.opacity(0.1))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@@ -348,10 +348,10 @@ struct EditorView: View {
Text("选择视频中的某一帧作为 Live Photo 的封面")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
.padding(16)
.background(Color.secondary.opacity(0.1))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@@ -368,7 +368,7 @@ struct EditorView: View {
.font(.headline)
Text("使用 AI 提升封面画质")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
}
}
@@ -388,7 +388,7 @@ struct EditorView: View {
.scaleEffect(0.8)
Text("正在下载 AI 模型...")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
ProgressView(value: aiModelDownloadProgress)
@@ -396,7 +396,7 @@ struct EditorView: View {
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
.font(.caption2)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
.padding(.leading, 4)
}
@@ -434,7 +434,7 @@ struct EditorView: View {
.font(.caption)
}
}
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
.padding(.leading, 4)
}
@@ -445,7 +445,7 @@ struct EditorView: View {
.font(.caption)
Text("当前设备不支持 AI 增强")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
.padding(.top, 4)
}
@@ -472,7 +472,7 @@ struct EditorView: View {
.font(.headline)
Text("适用于较旧设备或生成失败时")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
}
}
@@ -509,12 +509,12 @@ struct EditorView: View {
.font(.caption)
}
}
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
.padding(.leading, 4)
}
}
.padding(16)
.background(Color.secondary.opacity(0.1))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@@ -543,7 +543,7 @@ struct EditorView: View {
Text(suggestion.description)
.font(.caption)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
if let actionText = suggestion.actionText {
Button {
@@ -804,7 +804,7 @@ struct AspectRatioButton: View {
VStack(spacing: 4) {
//
RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? Color.accentColor : Color.secondary, lineWidth: 2)
.stroke(isSelected ? Color.accentColor : Color.textSecondary, lineWidth: 2)
.frame(width: iconWidth, height: iconHeight)
.background(
isSelected ? Color.accentColor.opacity(0.1) : Color.clear
@@ -814,14 +814,18 @@ struct AspectRatioButton: View {
Text(template.displayName)
.font(.caption2)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? .primary : .secondary)
.foregroundColor(isSelected ? .textPrimary : .textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.padding(.vertical, DesignTokens.Spacing.sm)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "accessibility.aspectRatio \(template.displayName)"))
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var iconWidth: CGFloat {
@@ -957,7 +961,7 @@ struct ScaleButtonStyle: ButtonStyle {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
.animation(DesignTokens.Animation.quick, value: configuration.isPressed)
}
}

View File

@@ -38,7 +38,7 @@ struct HomeView: View {
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
SoftIconButton("gearshape") {
SoftIconButton("gearshape", accessibilityLabel: String(localized: "accessibility.settings")) {
appState.navigateTo(.settings)
}
}
@@ -351,13 +351,17 @@ struct RecentWorkCard: View {
}
.buttonStyle(.plain)
.scaleEffect(isPressed ? 0.97 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
.animation(DesignTokens.Animation.spring, value: isPressed)
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
.onAppear {
thumbnailLoader.load(assetId: work.assetLocalIdentifier)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "accessibility.livePhoto"))
.accessibilityHint(Text("\(work.aspectRatioDisplayName), \(work.createdAt.formatted(.relative(presentation: .named)))"))
.accessibilityAddTraits(.isButton)
}
}
@@ -366,7 +370,7 @@ struct HomeButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed)
.animation(DesignTokens.Animation.spring, value: configuration.isPressed)
}
}

View File

@@ -63,7 +63,7 @@ struct SettingsView: View {
Label(String(localized: "settings.cacheSize"), systemImage: "internaldrive")
Spacer()
Text(cacheSize)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
Button(role: .destructive) {
@@ -111,7 +111,7 @@ struct SettingsView: View {
Label(String(localized: "settings.version"), systemImage: "info.circle")
Spacer()
Text(appVersion)
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
}
NavigationLink {
@@ -175,7 +175,7 @@ struct SettingsView: View {
.labelStyle(.iconOnly)
case .notDetermined:
Label(String(localized: "settings.notDetermined"), systemImage: "questionmark.circle.fill")
.foregroundStyle(.secondary)
.foregroundColor(.textSecondary)
.labelStyle(.iconOnly)
@unknown default:
EmptyView()

View File

@@ -171,7 +171,7 @@ struct WallpaperGuideView: View {
)
}
.padding(12)
.background(Color.secondary.opacity(0.1))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
@@ -316,7 +316,7 @@ struct FAQRow: View {
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.08))
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}