mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
cache the last image an ios widget fetched and use if a fetch fails in a future timeline build
This commit is contained in:
parent
172388c455
commit
e211b47fad
@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@ -117,8 +117,6 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -473,10 +471,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@ -505,10 +507,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
func buildEntry(
|
|
||||||
api: ImmichAPI,
|
|
||||||
asset: Asset,
|
|
||||||
dateOffset: Int,
|
|
||||||
subtitle: String? = nil
|
|
||||||
)
|
|
||||||
async throws -> ImageEntry
|
|
||||||
{
|
|
||||||
let entryDate = Calendar.current.date(
|
|
||||||
byAdding: .minute,
|
|
||||||
value: dateOffset * 20,
|
|
||||||
to: Date.now
|
|
||||||
)!
|
|
||||||
let image = try await api.fetchImage(asset: asset)
|
|
||||||
|
|
||||||
return ImageEntry(date: entryDate, image: image, subtitle: subtitle, deepLink: asset.deepLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateRandomEntries(
|
|
||||||
api: ImmichAPI,
|
|
||||||
now: Date,
|
|
||||||
count: Int,
|
|
||||||
albumId: String? = nil,
|
|
||||||
subtitle: String? = nil
|
|
||||||
)
|
|
||||||
async throws -> [ImageEntry]
|
|
||||||
{
|
|
||||||
|
|
||||||
var entries: [ImageEntry] = []
|
|
||||||
let albumIds = albumId != nil ? [albumId!] : []
|
|
||||||
|
|
||||||
let randomAssets = try await api.fetchSearchResults(
|
|
||||||
with: SearchFilters(size: count, albumIds: albumIds)
|
|
||||||
)
|
|
||||||
|
|
||||||
await withTaskGroup(of: ImageEntry?.self) { group in
|
|
||||||
for (dateOffset, asset) in randomAssets.enumerated() {
|
|
||||||
group.addTask {
|
|
||||||
return try? await buildEntry(
|
|
||||||
api: api,
|
|
||||||
asset: asset,
|
|
||||||
dateOffset: dateOffset,
|
|
||||||
subtitle: subtitle
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await result in group {
|
|
||||||
if let entry = result {
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
170
mobile/ios/WidgetExtension/ImageEntry.swift
Normal file
170
mobile/ios/WidgetExtension/ImageEntry.swift
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
typealias EntryMetadata = ImageEntry.Metadata
|
||||||
|
|
||||||
|
struct ImageEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
var image: UIImage?
|
||||||
|
var metadata: Metadata = Metadata()
|
||||||
|
|
||||||
|
struct Metadata: Codable {
|
||||||
|
var subtitle: String? = nil
|
||||||
|
var error: WidgetError? = nil
|
||||||
|
var deepLink: URL? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resizes the stored image to a maximum width of 450 pixels
|
||||||
|
mutating func resize() {
|
||||||
|
if image == nil || image!.size.height < 450 || image!.size.width < 450 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image = image?.resized(toWidth: 450)
|
||||||
|
|
||||||
|
if image == nil {
|
||||||
|
metadata.error = .unableToResize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func build(
|
||||||
|
api: ImmichAPI,
|
||||||
|
asset: Asset,
|
||||||
|
dateOffset: Int,
|
||||||
|
subtitle: String? = nil
|
||||||
|
)
|
||||||
|
async throws -> Self
|
||||||
|
{
|
||||||
|
let entryDate = Calendar.current.date(
|
||||||
|
byAdding: .minute,
|
||||||
|
value: dateOffset * 20,
|
||||||
|
to: Date.now
|
||||||
|
)!
|
||||||
|
let image = try await api.fetchImage(asset: asset)
|
||||||
|
|
||||||
|
return Self(
|
||||||
|
date: entryDate,
|
||||||
|
image: image,
|
||||||
|
metadata: ImageEntry.Metadata(
|
||||||
|
subtitle: subtitle,
|
||||||
|
deepLink: asset.deepLink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cache(for key: String) throws {
|
||||||
|
if let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
|
||||||
|
) {
|
||||||
|
let imageURL = containerURL.appendingPathComponent("\(key)_image.txt")
|
||||||
|
let metadataURL = containerURL.appendingPathComponent(
|
||||||
|
"\(key)_metadata.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// build metadata JSON
|
||||||
|
let entryMetadata = try JSONEncoder().encode(self.metadata)
|
||||||
|
|
||||||
|
// write to disk
|
||||||
|
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
|
||||||
|
try entryMetadata.write(to: metadataURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadCached(for key: String, at date: Date = Date.now)
|
||||||
|
-> ImageEntry?
|
||||||
|
{
|
||||||
|
if let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
|
||||||
|
) {
|
||||||
|
let imageURL = containerURL.appendingPathComponent("\(key)_image.txt")
|
||||||
|
let metadataURL = containerURL.appendingPathComponent(
|
||||||
|
"\(key)_metadata.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let imageData = try? Data(contentsOf: imageURL),
|
||||||
|
let metadataJSON = try? Data(contentsOf: metadataURL)
|
||||||
|
else {
|
||||||
|
// cache miss
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let decodedMetadata = try? JSONDecoder().decode(
|
||||||
|
Metadata.self,
|
||||||
|
from: metadataJSON
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageEntry(
|
||||||
|
date: date,
|
||||||
|
image: UIImage(data: imageData),
|
||||||
|
metadata: decodedMetadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func handleCacheFallback(
|
||||||
|
for key: String,
|
||||||
|
error: WidgetError = .fetchFailed
|
||||||
|
) -> Timeline<ImageEntry> {
|
||||||
|
var timelineEntry = ImageEntry(
|
||||||
|
date: Date.now,
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// skip cache if album not found or no login
|
||||||
|
// we want to show these errors to the user since without intervention,
|
||||||
|
// it will never succeed
|
||||||
|
if error != .noLogin && error != .albumNotFound {
|
||||||
|
if let cachedEntry = ImageEntry.loadCached(for: key) {
|
||||||
|
timelineEntry = cachedEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: [timelineEntry], policy: .atEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomEntries(
|
||||||
|
api: ImmichAPI,
|
||||||
|
now: Date,
|
||||||
|
count: Int,
|
||||||
|
albumId: String? = nil,
|
||||||
|
subtitle: String? = nil
|
||||||
|
)
|
||||||
|
async throws -> [ImageEntry]
|
||||||
|
{
|
||||||
|
|
||||||
|
var entries: [ImageEntry] = []
|
||||||
|
let albumIds = albumId != nil ? [albumId!] : []
|
||||||
|
|
||||||
|
let randomAssets = try await api.fetchSearchResults(
|
||||||
|
with: SearchFilters(size: count, albumIds: albumIds)
|
||||||
|
)
|
||||||
|
|
||||||
|
await withTaskGroup(of: ImageEntry?.self) { group in
|
||||||
|
for (dateOffset, asset) in randomAssets.enumerated() {
|
||||||
|
group.addTask {
|
||||||
|
return try? await ImageEntry.build(
|
||||||
|
api: api,
|
||||||
|
asset: asset,
|
||||||
|
dateOffset: dateOffset,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in group {
|
||||||
|
if let entry = result {
|
||||||
|
entries.append(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
@ -1,27 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
struct ImageEntry: TimelineEntry {
|
|
||||||
let date: Date
|
|
||||||
var image: UIImage?
|
|
||||||
var subtitle: String? = nil
|
|
||||||
var error: WidgetError? = nil
|
|
||||||
var deepLink: URL? = nil
|
|
||||||
|
|
||||||
// Resizes the stored image to a maximum width of 450 pixels
|
|
||||||
mutating func resize() {
|
|
||||||
if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
image = image?.resized(toWidth: 450)
|
|
||||||
|
|
||||||
if image == nil {
|
|
||||||
error = .unableToResize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ImmichWidgetView: View {
|
struct ImmichWidgetView: View {
|
||||||
var entry: ImageEntry
|
var entry: ImageEntry
|
||||||
|
|
||||||
@ -29,7 +8,7 @@ struct ImmichWidgetView: View {
|
|||||||
if entry.image == nil {
|
if entry.image == nil {
|
||||||
VStack {
|
VStack {
|
||||||
Image("LaunchImage")
|
Image("LaunchImage")
|
||||||
Text(entry.error?.errorDescription ?? "")
|
Text(entry.metadata.error?.errorDescription ?? "")
|
||||||
.minimumScaleFactor(0.25)
|
.minimumScaleFactor(0.25)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -44,7 +23,7 @@ struct ImmichWidgetView: View {
|
|||||||
)
|
)
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
if let subtitle = entry.subtitle {
|
if let subtitle = entry.metadata.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@ -55,7 +34,7 @@ struct ImmichWidgetView: View {
|
|||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
.widgetURL(entry.deepLink)
|
.widgetURL(entry.metadata.deepLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,7 +49,9 @@ struct ImmichWidgetView: View {
|
|||||||
ImageEntry(
|
ImageEntry(
|
||||||
date: date,
|
date: date,
|
||||||
image: UIImage(named: "ImmichLogo"),
|
image: UIImage(named: "ImmichLogo"),
|
||||||
|
metadata: ImageEntry.Metadata(
|
||||||
subtitle: "1 year ago"
|
subtitle: "1 year ago"
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
enum WidgetError: Error {
|
enum WidgetError: Error, Codable {
|
||||||
case noLogin
|
case noLogin
|
||||||
case fetchFailed
|
case fetchFailed
|
||||||
case unknown
|
case unknown
|
||||||
@ -75,6 +75,8 @@ struct Album: Codable {
|
|||||||
let albumName: String
|
let albumName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let IMMICH_SHARE_GROUP = "group.app.immich.share"
|
||||||
|
|
||||||
// MARK: API
|
// MARK: API
|
||||||
|
|
||||||
class ImmichAPI {
|
class ImmichAPI {
|
||||||
@ -86,7 +88,7 @@ class ImmichAPI {
|
|||||||
|
|
||||||
init() async throws {
|
init() async throws {
|
||||||
// fetch the credentials from the UserDefaults store that dart placed here
|
// fetch the credentials from the UserDefaults store that dart placed here
|
||||||
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
|
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
|
||||||
let serverURL = defaults.string(forKey: "widget_server_url"),
|
let serverURL = defaults.string(forKey: "widget_server_url"),
|
||||||
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
||||||
else {
|
else {
|
||||||
@ -190,17 +192,24 @@ class ImmichAPI {
|
|||||||
throw .invalidURL
|
throw .invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) else {
|
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
|
||||||
|
else {
|
||||||
throw .invalidURL
|
throw .invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
let decodeOptions: [NSString: Any] = [
|
let decodeOptions: [NSString: Any] = [
|
||||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
kCGImageSourceThumbnailMaxPixelSize: 400,
|
kCGImageSourceThumbnailMaxPixelSize: 400,
|
||||||
kCGImageSourceCreateThumbnailWithTransform: true
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
]
|
]
|
||||||
|
|
||||||
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions as CFDictionary) else {
|
guard
|
||||||
|
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
|
||||||
|
imageSource,
|
||||||
|
0,
|
||||||
|
decodeOptions as CFDictionary
|
||||||
|
)
|
||||||
|
else {
|
||||||
throw .fetchFailed
|
throw .fetchFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,7 +10,10 @@ extension UIImage {
|
|||||||
/// Crops the image to ensure width and height do not exceed maxSize.
|
/// Crops the image to ensure width and height do not exceed maxSize.
|
||||||
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
||||||
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
||||||
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
|
let canvas = CGSize(
|
||||||
|
width: width,
|
||||||
|
height: CGFloat(ceil(width / size.width * size.height))
|
||||||
|
)
|
||||||
let format = imageRendererFormat
|
let format = imageRendererFormat
|
||||||
format.opaque = isOpaque
|
format.opaque = isOpaque
|
||||||
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
||||||
|
@ -21,19 +21,31 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
) {
|
) {
|
||||||
Task {
|
Task {
|
||||||
guard let api = try? await ImmichAPI() else {
|
guard let api = try? await ImmichAPI() else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .noLogin))
|
completion(
|
||||||
|
ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .noLogin)
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let memories = try? await api.fetchMemory(for: Date.now)
|
guard let memories = try? await api.fetchMemory(for: Date.now)
|
||||||
else {
|
else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
completion(
|
||||||
|
ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .fetchFailed)
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for memory in memories {
|
for memory in memories {
|
||||||
if let asset = memory.assets.first(where: { $0.type == .image }),
|
if let asset = memory.assets.first(where: { $0.type == .image }),
|
||||||
var entry = try? await buildEntry(
|
var entry = try? await ImageEntry.build(
|
||||||
api: api,
|
api: api,
|
||||||
asset: asset,
|
asset: asset,
|
||||||
dateOffset: 0,
|
dateOffset: 0,
|
||||||
@ -52,18 +64,30 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
with: SearchFilters(size: 1)
|
with: SearchFilters(size: 1)
|
||||||
).first
|
).first
|
||||||
else {
|
else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
completion(
|
||||||
|
ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .fetchFailed)
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard
|
||||||
var imageEntry = try? await buildEntry(
|
var imageEntry = try? await ImageEntry.build(
|
||||||
api: api,
|
api: api,
|
||||||
asset: randomImage,
|
asset: randomImage,
|
||||||
dateOffset: 0
|
dateOffset: 0
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
completion(
|
||||||
|
ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .fetchFailed)
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,9 +104,12 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
var entries: [ImageEntry] = []
|
var entries: [ImageEntry] = []
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|
||||||
|
let cacheKey = "memory_\(context.family.rawValue)"
|
||||||
|
|
||||||
guard let api = try? await ImmichAPI() else {
|
guard let api = try? await ImmichAPI() else {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
|
completion(
|
||||||
completion(Timeline(entries: entries, policy: .atEnd))
|
ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +122,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
for asset in memory.assets {
|
for asset in memory.assets {
|
||||||
if asset.type == .image && totalAssets < 12 {
|
if asset.type == .image && totalAssets < 12 {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
try? await buildEntry(
|
try? await ImageEntry.build(
|
||||||
api: api,
|
api: api,
|
||||||
asset: asset,
|
asset: asset,
|
||||||
dateOffset: totalAssets,
|
dateOffset: totalAssets,
|
||||||
@ -132,7 +159,8 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
// If we fail to fetch images, we still want to add an entry
|
// If we fail to fetch images, we still want to add an entry
|
||||||
// with a nil image and an error
|
// with a nil image and an error
|
||||||
if entries.count == 0 {
|
if entries.count == 0 {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
completion(ImageEntry.handleCacheFallback(for: cacheKey))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize all images to something that can be stored by iOS
|
// Resize all images to something that can be stored by iOS
|
||||||
@ -140,6 +168,9 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
entries[i].resize()
|
entries[i].resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cache the last image
|
||||||
|
try? entries.last!.cache(for: cacheKey)
|
||||||
|
|
||||||
completion(Timeline(entries: entries, policy: .atEnd))
|
completion(Timeline(entries: entries, policy: .atEnd))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
|
|||||||
|
|
||||||
struct ImmichRandomProvider: AppIntentTimelineProvider {
|
struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||||
func placeholder(in context: Context) -> ImageEntry {
|
func placeholder(in context: Context) -> ImageEntry {
|
||||||
ImageEntry(date: Date(), image: nil)
|
ImageEntry(date: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(
|
func snapshot(
|
||||||
@ -64,7 +64,11 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
-> ImageEntry
|
-> ImageEntry
|
||||||
{
|
{
|
||||||
guard let api = try? await ImmichAPI() else {
|
guard let api = try? await ImmichAPI() else {
|
||||||
return ImageEntry(date: Date(), image: nil, error: .noLogin)
|
return ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .noLogin)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard
|
||||||
@ -72,17 +76,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
with: SearchFilters(size: 1)
|
with: SearchFilters(size: 1)
|
||||||
).first
|
).first
|
||||||
else {
|
else {
|
||||||
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
return ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .fetchFailed)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard
|
||||||
var entry = try? await buildEntry(
|
var entry = try? await ImageEntry.build(
|
||||||
api: api,
|
api: api,
|
||||||
asset: randomImage,
|
asset: randomImage,
|
||||||
dateOffset: 0
|
dateOffset: 0
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
return ImageEntry(
|
||||||
|
date: Date(),
|
||||||
|
image: nil,
|
||||||
|
metadata: EntryMetadata(error: .fetchFailed)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.resize()
|
entry.resize()
|
||||||
@ -99,30 +111,34 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
var entries: [ImageEntry] = []
|
var entries: [ImageEntry] = []
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|
||||||
// If we don't have a server config, return an entry with an error
|
|
||||||
guard let api = try? await ImmichAPI() else {
|
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
|
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// nil if album is NONE or nil
|
// nil if album is NONE or nil
|
||||||
let albumId =
|
let albumId =
|
||||||
configuration.album?.id != "NONE" ? configuration.album?.id : nil
|
configuration.album?.id != "NONE" ? configuration.album?.id : nil
|
||||||
var albumName: String? = albumId != nil ? configuration.album?.albumName : nil
|
let albumName: String? =
|
||||||
|
albumId != nil ? configuration.album?.albumName : nil
|
||||||
|
|
||||||
|
let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)"
|
||||||
|
|
||||||
|
// If we don't have a server config, return an entry with an error
|
||||||
|
guard let api = try? await ImmichAPI() else {
|
||||||
|
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
|
||||||
|
}
|
||||||
|
|
||||||
if albumId != nil {
|
if albumId != nil {
|
||||||
// make sure the album exists on server, otherwise show error
|
// make sure the album exists on server, otherwise show error
|
||||||
guard let albums = try? await api.fetchAlbums() else {
|
guard let albums = try? await api.fetchAlbums() else {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
return ImageEntry.handleCacheFallback(for: cacheKey)
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !albums.contains(where: { $0.id == albumId }) {
|
if !albums.contains(where: { $0.id == albumId }) {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound))
|
return ImageEntry.handleCacheFallback(
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
for: cacheKey,
|
||||||
|
error: .albumNotFound
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// build entries
|
||||||
entries.append(
|
entries.append(
|
||||||
contentsOf: (try? await generateRandomEntries(
|
contentsOf: (try? await generateRandomEntries(
|
||||||
api: api,
|
api: api,
|
||||||
@ -134,9 +150,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
?? []
|
?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
// If we fail to fetch images, we still want to add an entry with a nil image and an error
|
// Load or save a cached asset for when network conditions are bad
|
||||||
if entries.count == 0 {
|
if entries.count == 0 {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
return ImageEntry.handleCacheFallback(for: cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize all images to something that can be stored by iOS
|
// Resize all images to something that can be stored by iOS
|
||||||
@ -144,6 +160,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
entries[i].resize()
|
entries[i].resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cache the last image
|
||||||
|
try? entries.last!.cache(for: cacheKey)
|
||||||
|
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user