diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt new file mode 100644 index 0000000000..7dc61e1bdb --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt @@ -0,0 +1,127 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.images + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object ThumbnailsPigeonUtils { + + fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() +private open class ThumbnailsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface ThumbnailApi { + fun getThumbnail(assetId: String, width: Long, height: Long, callback: (Result) -> Unit) + + companion object { + /** The codec used by ThumbnailApi. */ + val codec: MessageCodec by lazy { + ThumbnailsPigeonCodec() + } + /** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbnail$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val assetIdArg = args[0] as String + val widthArg = args[1] as Long + val heightArg = args[2] as Long + api.getThumbnail(assetIdArg, widthArg, heightArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(ThumbnailsPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(ThumbnailsPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class PlatformThumbnailApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by PlatformThumbnailApi. */ + val codec: MessageCodec by lazy { + ThumbnailsPigeonCodec() + } + } + fun getThumbnail(assetIdArg: String, widthArg: Long, heightArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(assetIdArg, widthArg, heightArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + val output = it[0] as ByteArray? + callback(Result.success(output)) + } + } else { + callback(Result.failure(ThumbnailsPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index fb0908e8b6..125cb4babf 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -24,6 +24,9 @@ FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; + FE1BEAC92E264F8400D7F138 /* Thumbhash.swift in Resources */ = {isa = PBXBuildFile; fileRef = FE1BEAC82E264F8400D7F138 /* Thumbhash.swift */; }; + FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; + FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -44,16 +47,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -102,6 +95,10 @@ FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; + FE1BEAC82E264F8400D7F138 /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; + FED3B1472E253B110030FD97 /* Flutter.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:S8QB4VV633:FLUTTER.IO LLC"; lastKnownFileType = wrapper.xcframework; name = Flutter.xcframework; path = ../../../Flutter/bin/cache/artifacts/engine/ios/Flutter.xcframework; sourceTree = ""; }; + FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = ""; }; + FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -117,8 +114,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -177,6 +172,7 @@ 1754452DD81DA6620E279E51 /* Frameworks */ = { isa = PBXGroup; children = ( + FED3B1472E253B110030FD97 /* Flutter.xcframework */, 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */, 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */, F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */, @@ -243,6 +239,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + FED3B1952E253E9B0030FD97 /* Images */, ); path = Runner; sourceTree = ""; @@ -258,6 +255,16 @@ path = ShareExtension; sourceTree = ""; }; + FED3B1952E253E9B0030FD97 /* Images */ = { + isa = PBXGroup; + children = ( + FE1BEAC82E264F8400D7F138 /* Thumbhash.swift */, + FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */, + FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */, + ); + path = Images; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -270,7 +277,6 @@ 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */, @@ -379,6 +385,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + FE1BEAC92E264F8400D7F138 /* Thumbhash.swift in Resources */, 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, @@ -523,6 +530,8 @@ files = ( 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */, + FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, ); @@ -651,7 +660,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; CUSTOM_GROUP_ID = group.app.immich.share; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -660,7 +669,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.121.0; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile; PRODUCT_NAME = "Immich-Profile"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -795,7 +804,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; CUSTOM_GROUP_ID = group.app.immich.share; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -804,7 +813,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.121.0; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug; PRODUCT_NAME = "Immich-Debug"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -825,7 +834,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; CUSTOM_GROUP_ID = group.app.immich.share; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -834,7 +843,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.121.0; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich; PRODUCT_NAME = Immich; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -858,7 +867,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -875,7 +884,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.Widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -901,7 +910,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -917,7 +926,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.Widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -941,7 +950,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -957,7 +966,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile.Widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -981,7 +990,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; CUSTOM_GROUP_ID = group.app.immich.share; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -998,7 +1007,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1025,7 +1034,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; CUSTOM_GROUP_ID = group.app.immich.share; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -1041,7 +1050,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1066,7 +1075,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 210; CUSTOM_GROUP_ID = group.app.immich.share; - DEVELOPMENT_TEAM = 2F67MQ8R79; + DEVELOPMENT_TEAM = 33MF3D8ZGA; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -1082,7 +1091,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 55d08adc6a..dedda5bd12 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -25,6 +25,7 @@ import UIKit let controller: FlutterViewController = window?.rootViewController as! FlutterViewController NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl()) + ThumbnailApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ThumbnailApiImpl()) BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { diff --git a/mobile/ios/Runner/Images/Thumbhash.swift b/mobile/ios/Runner/Images/Thumbhash.swift new file mode 100644 index 0000000000..8e461f1710 --- /dev/null +++ b/mobile/ios/Runner/Images/Thumbhash.swift @@ -0,0 +1,649 @@ +import Foundation + +// NOTE: Swift has an exponential-time type checker and compiling very simple +// expressions can easily take many seconds, especially when expressions involve +// numeric type constructors. +// +// This file deliberately breaks compound expressions up into separate variables +// to improve compile time even though this comes at the expense of readability. +// This is a known workaround for this deficiency in the Swift compiler. +// +// The following command is helpful when debugging Swift compile time issues: +// +// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies +// +// These optimizations brought the compile time for this file from around 2.5 +// seconds to around 250ms (10x faster). + +// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is +// really awful. Debug builds compile a very generic indexing iterator thing +// that makes many nested calls for every iteration, which makes debug-build +// performance crawl. +// +// This file deliberately avoids for-in loops that loop for more than a few +// times to improve debug-build run time even though this comes at the expense +// of readability. Similarly unsafe pointers are used instead of array getters +// to avoid unnecessary bounds checks, which have extra overhead in debug builds. +// +// These optimizations brought the run time to encode and decode 10 ThumbHashes +// in debug mode from 700ms to 70ms (10x faster). + +func rgbaToThumbHash(w: Int, h: Int, rgba: Data) -> Data { + // Encoding an image larger than 100x100 is slow with no benefit + assert(w <= 100 && h <= 100) + assert(rgba.count == w * h * 4) + + // Determine the average color + var avg_r: Float32 = 0 + var avg_g: Float32 = 0 + var avg_b: Float32 = 0 + var avg_a: Float32 = 0 + rgba.withUnsafeBytes { rgba in + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let alpha = Float32(rgba[3]) / 255 + avg_r += alpha / 255 * Float32(rgba[0]) + avg_g += alpha / 255 * Float32(rgba[1]) + avg_b += alpha / 255 * Float32(rgba[2]) + avg_a += alpha + rgba = rgba.advanced(by: 4) + i += 1 + } + } + if avg_a > 0 { + avg_r /= avg_a + avg_g /= avg_a + avg_b /= avg_a + } + + let hasAlpha = avg_a < Float32(w * h) + let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha + let imax_wh = max(w, h) + let iwl_limit = l_limit * w + let ihl_limit = l_limit * h + let fmax_wh = Float32(imax_wh) + let fwl_limit = Float32(iwl_limit) + let fhl_limit = Float32(ihl_limit) + let flx = round(fwl_limit / fmax_wh) + let fly = round(fhl_limit / fmax_wh) + var lx = Int(flx) + var ly = Int(fly) + lx = max(1, lx) + ly = max(1, ly) + var lpqa = [Float32](repeating: 0, count: w * h * 4) + + // Convert the image from RGBA to LPQA (composite atop the average color) + rgba.withUnsafeBytes { rgba in + lpqa.withUnsafeMutableBytes { lpqa in + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + var lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count) + let n = w * h + var i = 0 + while i < n { + let alpha = Float32(rgba[3]) / 255 + let r = avg_r * (1 - alpha) + alpha / 255 * Float32(rgba[0]) + let g = avg_g * (1 - alpha) + alpha / 255 * Float32(rgba[1]) + let b = avg_b * (1 - alpha) + alpha / 255 * Float32(rgba[2]) + lpqa[0] = (r + g + b) / 3 + lpqa[1] = (r + g) / 2 - b + lpqa[2] = r - g + lpqa[3] = alpha + rgba = rgba.advanced(by: 4) + lpqa = lpqa.advanced(by: 4) + i += 1 + } + } + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + let encodeChannel = { (channel: UnsafePointer, nx: Int, ny: Int) -> (Float32, [Float32], Float32) in + var dc: Float32 = 0 + var ac: [Float32] = [] + var scale: Float32 = 0 + var fx = [Float32](repeating: 0, count: w) + fx.withUnsafeMutableBytes { fx in + let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count) + var cy = 0 + while cy < ny { + var cx = 0 + while cx * ny < nx * (ny - cy) { + var ptr = channel + var f: Float32 = 0 + var x = 0 + while x < w { + let fw = Float32(w) + let fxx = Float32(x) + let fcx = Float32(cx) + fx[x] = cos(Float32.pi / fw * fcx * (fxx + 0.5)) + x += 1 + } + var y = 0 + while y < h { + let fh = Float32(h) + let fyy = Float32(y) + let fcy = Float32(cy) + let fy = cos(Float32.pi / fh * fcy * (fyy + 0.5)) + var x = 0 + while x < w { + f += ptr.pointee * fx[x] * fy + x += 1 + ptr = ptr.advanced(by: 4) + } + y += 1 + } + f /= Float32(w * h) + if cx > 0 || cy > 0 { + ac.append(f) + scale = max(scale, abs(f)) + } else { + dc = f + } + cx += 1 + } + cy += 1 + } + } + if scale > 0 { + let n = ac.count + var i = 0 + while i < n { + ac[i] = 0.5 + 0.5 / scale * ac[i] + i += 1 + } + } + return (dc, ac, scale) + } + let ( + (l_dc, l_ac, l_scale), + (p_dc, p_ac, p_scale), + (q_dc, q_ac, q_scale), + (a_dc, a_ac, a_scale) + ) = lpqa.withUnsafeBytes { lpqa in + let lpqa = lpqa.baseAddress!.bindMemory(to: Float32.self, capacity: lpqa.count) + return ( + encodeChannel(lpqa, max(3, lx), max(3, ly)), + encodeChannel(lpqa.advanced(by: 1), 3, 3), + encodeChannel(lpqa.advanced(by: 2), 3, 3), + hasAlpha ? encodeChannel(lpqa.advanced(by: 3), 5, 5) : (1, [], 1) + ) + } + + // Write the constants + let isLandscape = w > h + let fl_dc = round(63.0 * l_dc) + let fp_dc = round(31.5 + 31.5 * p_dc) + let fq_dc = round(31.5 + 31.5 * q_dc) + let fl_scale = round(31.0 * l_scale) + let il_dc = UInt32(fl_dc) + let ip_dc = UInt32(fp_dc) + let iq_dc = UInt32(fq_dc) + let il_scale = UInt32(fl_scale) + let ihasAlpha = UInt32(hasAlpha ? 1 : 0) + let header24 = il_dc | (ip_dc << 6) | (iq_dc << 12) | (il_scale << 18) | (ihasAlpha << 23) + let fp_scale = round(63.0 * p_scale) + let fq_scale = round(63.0 * q_scale) + let ilxy = UInt16(isLandscape ? ly : lx) + let ip_scale = UInt16(fp_scale) + let iq_scale = UInt16(fq_scale) + let iisLandscape = UInt16(isLandscape ? 1 : 0) + let header16 = ilxy | (ip_scale << 3) | (iq_scale << 9) | (iisLandscape << 15) + var hash = Data(capacity: 25) + hash.append(UInt8(header24 & 255)) + hash.append(UInt8((header24 >> 8) & 255)) + hash.append(UInt8(header24 >> 16)) + hash.append(UInt8(header16 & 255)) + hash.append(UInt8(header16 >> 8)) + var isOdd = false + if hasAlpha { + let fa_dc = round(15.0 * a_dc) + let fa_scale = round(15.0 * a_scale) + let ia_dc = UInt8(fa_dc) + let ia_scale = UInt8(fa_scale) + hash.append(ia_dc | (ia_scale << 4)) + } + + // Write the varying factors + for ac in [l_ac, p_ac, q_ac] { + for f in ac { + let f15 = round(15.0 * f) + let i15 = UInt8(f15) + if isOdd { + hash[hash.count - 1] |= i15 << 4 + } else { + hash.append(i15) + } + isOdd = !isOdd + } + } + if hasAlpha { + for f in a_ac { + let f15 = round(15.0 * f) + let i15 = UInt8(f15) + if isOdd { + hash[hash.count - 1] |= i15 << 4 + } else { + hash.append(i15) + } + isOdd = !isOdd + } + } + return hash +} + +func thumbHashToRGBA(hash: Data) -> (Int, Int, Data) { + // Read the constants + let h0 = UInt32(hash[0]) + let h1 = UInt32(hash[1]) + let h2 = UInt32(hash[2]) + let h3 = UInt16(hash[3]) + let h4 = UInt16(hash[4]) + let header24 = h0 | (h1 << 8) | (h2 << 16) + let header16 = h3 | (h4 << 8) + let il_dc = header24 & 63 + let ip_dc = (header24 >> 6) & 63 + let iq_dc = (header24 >> 12) & 63 + var l_dc = Float32(il_dc) + var p_dc = Float32(ip_dc) + var q_dc = Float32(iq_dc) + l_dc = l_dc / 63 + p_dc = p_dc / 31.5 - 1 + q_dc = q_dc / 31.5 - 1 + let il_scale = (header24 >> 18) & 31 + var l_scale = Float32(il_scale) + l_scale = l_scale / 31 + let hasAlpha = (header24 >> 23) != 0 + let ip_scale = (header16 >> 3) & 63 + let iq_scale = (header16 >> 9) & 63 + var p_scale = Float32(ip_scale) + var q_scale = Float32(iq_scale) + p_scale = p_scale / 63 + q_scale = q_scale / 63 + let isLandscape = (header16 >> 15) != 0 + let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7) + let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) + let lx = Int(lx16) + let ly = Int(ly16) + var a_dc = Float32(1) + var a_scale = Float32(1) + if hasAlpha { + let ia_dc = hash[5] & 15 + let ia_scale = hash[5] >> 4 + a_dc = Float32(ia_dc) + a_scale = Float32(ia_scale) + a_dc /= 15 + a_scale /= 15 + } + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = hasAlpha ? 6 : 5 + var ac_index = 0 + let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in + var ac: [Float32] = [] + for cy in 0 ..< ny { + var cx = cy > 0 ? 0 : 1 + while cx * ny < nx * (ny - cy) { + let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15; + var fac = Float32(iac) + fac = (fac / 7.5 - 1) * scale + ac.append(fac) + ac_index += 1 + cx += 1 + } + } + return ac + } + let l_ac = decodeChannel(lx, ly, l_scale) + let p_ac = decodeChannel(3, 3, p_scale * 1.25) + let q_ac = decodeChannel(3, 3, q_scale * 1.25) + let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : [] + + // Decode using the DCT into RGB + let ratio = thumbHashToApproximateAspectRatio(hash: hash) + let fw = round(ratio > 1 ? 32 : 32 * ratio) + let fh = round(ratio > 1 ? 32 / ratio : 32) + let w = Int(fw) + let h = Int(fh) + var rgba = Data(count: w * h * 4) + let cx_stop = max(lx, hasAlpha ? 5 : 3) + let cy_stop = max(ly, hasAlpha ? 5 : 3) + var fx = [Float32](repeating: 0, count: cx_stop) + var fy = [Float32](repeating: 0, count: cy_stop) + fx.withUnsafeMutableBytes { fx in + let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count) + fy.withUnsafeMutableBytes { fy in + let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count) + rgba.withUnsafeMutableBytes { rgba in + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + var y = 0 + while y < h { + var x = 0 + while x < w { + var l = l_dc + var p = p_dc + var q = q_dc + var a = a_dc + + // Precompute the coefficients + var cx = 0 + while cx < cx_stop { + let fw = Float32(w) + let fxx = Float32(x) + let fcx = Float32(cx) + fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx) + cx += 1 + } + var cy = 0 + while cy < cy_stop { + let fh = Float32(h) + let fyy = Float32(y) + let fcy = Float32(cy) + fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy) + cy += 1 + } + + // Decode L + var j = 0 + cy = 0 + while cy < ly { + var cx = cy > 0 ? 0 : 1 + let fy2 = fy[cy] * 2 + while cx * ly < lx * (ly - cy) { + l += l_ac[j] * fx[cx] * fy2 + j += 1 + cx += 1 + } + cy += 1 + } + + // Decode P and Q + j = 0 + cy = 0 + while cy < 3 { + var cx = cy > 0 ? 0 : 1 + let fy2 = fy[cy] * 2 + while cx < 3 - cy { + let f = fx[cx] * fy2 + p += p_ac[j] * f + q += q_ac[j] * f + j += 1 + cx += 1 + } + cy += 1 + } + + // Decode A + if hasAlpha { + j = 0 + cy = 0 + while cy < 5 { + var cx = cy > 0 ? 0 : 1 + let fy2 = fy[cy] * 2 + while cx < 5 - cy { + a += a_ac[j] * fx[cx] * fy2 + j += 1 + cx += 1 + } + cy += 1 + } + } + + // Convert to RGB + var b = l - 2 / 3 * p + var r = (3 * l - b + q) / 2 + var g = r - q + r = max(0, 255 * min(1, r)) + g = max(0, 255 * min(1, g)) + b = max(0, 255 * min(1, b)) + a = max(0, 255 * min(1, a)) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + rgba[3] = UInt8(a) + rgba = rgba.advanced(by: 4) + x += 1 + } + y += 1 + } + } + } + } + return (w, h, rgba) +} + +func thumbHashToAverageRGBA(hash: Data) -> (Float32, Float32, Float32, Float32) { + let h0 = UInt32(hash[0]) + let h1 = UInt32(hash[1]) + let h2 = UInt32(hash[2]) + let header = h0 | (h1 << 8) | (h2 << 16) + let il = header & 63 + let ip = (header >> 6) & 63 + let iq = (header >> 12) & 63 + var l = Float32(il) + var p = Float32(ip) + var q = Float32(iq) + l = l / 63 + p = p / 31.5 - 1 + q = q / 31.5 - 1 + let hasAlpha = (header >> 23) != 0 + var a = Float32(1) + if hasAlpha { + let ia = hash[5] & 15 + a = Float32(ia) + a = a / 15 + } + let b = l - 2 / 3 * p + let r = (3 * l - b + q) / 2 + let g = r - q + return ( + max(0, min(1, r)), + max(0, min(1, g)), + max(0, min(1, b)), + a + ) +} + +func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 { + let header = hash[3] + let hasAlpha = (hash[2] & 0x80) != 0 + let isLandscape = (hash[4] & 0x80) != 0 + let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7 + let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 + return Float32(lx) / Float32(ly) +} + +#if os(macOS) +import Cocoa + +func imageToThumbHash(image: NSImage) -> Data { + let size = image.size + let fw = round(100 * size.width / max(size.width, size.height)) + let fh = round(100 * size.height / max(size.width, size.height)) + let w = Int(fw) + let h = Int(fh) + var rgba = Data(count: w * h * 4) + rgba.withUnsafeMutableBytes { rgba in + var rect = NSRect(x: 0, y: 0, width: w, height: h) + if + let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil), + let space = (image.representations[0] as? NSBitmapImageRep)?.colorSpace.cgColorSpace, + let context = CGContext( + data: rgba.baseAddress, + width: w, + height: h, + bitsPerComponent: 8, + bytesPerRow: w * 4, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + { + context.draw(cgImage, in: rect) + + // Convert from premultiplied alpha to unpremultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let a = UInt16(rgba[3]) + if a > 0 && a < 255 { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + r = min(255, r * 255 / a) + g = min(255, g * 255 / a) + b = min(255, b * 255 / a) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + } + rgba = rgba.advanced(by: 4) + i += 1 + } + } + } + return rgbaToThumbHash(w: w, h: h, rgba: rgba) +} + +func thumbHashToImage(hash: Data) -> NSImage { + let (w, h, rgba) = thumbHashToRGBA(hash: hash) + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: w, + pixelsHigh: h, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: w * 4, + bitsPerPixel: 32 + )! + rgba.withUnsafeBytes { rgba in + // Convert from unpremultiplied alpha to premultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + var to = bitmap.bitmapData! + let n = w * h + var i = 0 + while i < n { + let a = rgba[3] + if a == 255 { + to[0] = rgba[0] + to[1] = rgba[1] + to[2] = rgba[2] + } else { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + let a = UInt16(a) + r = min(255, r * a / 255) + g = min(255, g * a / 255) + b = min(255, b * a / 255) + to[0] = UInt8(r) + to[1] = UInt8(g) + to[2] = UInt8(b) + } + to[3] = a + rgba = rgba.advanced(by: 4) + to = to.advanced(by: 4) + i += 1 + } + } + let image = NSImage(size: NSSize(width: w, height: h)) + image.addRepresentation(bitmap) + return image +} +#endif + +#if os(iOS) +import UIKit + +func imageToThumbHash(image: UIImage) -> Data { + let size = image.size + let w = Int(round(100 * size.width / max(size.width, size.height))) + let h = Int(round(100 * size.height / max(size.width, size.height))) + var rgba = Data(count: w * h * 4) + rgba.withUnsafeMutableBytes { rgba in + if + let space = image.cgImage?.colorSpace, + let context = CGContext( + data: rgba.baseAddress, + width: w, + height: h, + bitsPerComponent: 8, + bytesPerRow: w * 4, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + { + // EXIF orientation only works if you draw the UIImage, not the CGImage + context.concatenate(CGAffineTransform(1, 0, 0, -1, 0, CGFloat(h))) + UIGraphicsPushContext(context) + image.draw(in: CGRect(x: 0, y: 0, width: w, height: h)) + UIGraphicsPopContext() + + // Convert from premultiplied alpha to unpremultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let a = UInt16(rgba[3]) + if a > 0 && a < 255 { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + r = min(255, r * 255 / a) + g = min(255, g * 255 / a) + b = min(255, b * 255 / a) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + } + rgba = rgba.advanced(by: 4) + i += 1 + } + } + } + return rgbaToThumbHash(w: w, h: h, rgba: rgba) +} + +func thumbHashToImage(hash: Data) -> UIImage { + var (w, h, rgba) = thumbHashToRGBA(hash: hash) + rgba.withUnsafeMutableBytes { rgba in + // Convert from unpremultiplied alpha to premultiplied alpha + var rgba = rgba.baseAddress!.bindMemory(to: UInt8.self, capacity: rgba.count) + let n = w * h + var i = 0 + while i < n { + let a = UInt16(rgba[3]) + if a < 255 { + var r = UInt16(rgba[0]) + var g = UInt16(rgba[1]) + var b = UInt16(rgba[2]) + r = min(255, r * a / 255) + g = min(255, g * a / 255) + b = min(255, b * a / 255) + rgba[0] = UInt8(r) + rgba[1] = UInt8(g) + rgba[2] = UInt8(b) + } + rgba = rgba.advanced(by: 4) + i += 1 + } + } + let image = CGImage( + width: w, + height: h, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: w * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue), + provider: CGDataProvider(data: rgba as CFData)!, + decode: nil, + shouldInterpolate: true, + intent: .perceptual + ) + return UIImage(cgImage: image!) +} +#endif diff --git a/mobile/ios/Runner/Images/Thumbnails.g.swift b/mobile/ios/Runner/Images/Thumbnails.g.swift new file mode 100644 index 0000000000..9469a9c630 --- /dev/null +++ b/mobile/ios/Runner/Images/Thumbnails.g.swift @@ -0,0 +1,140 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +private class ThumbnailsPigeonCodecReader: FlutterStandardReader { +} + +private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter { +} + +private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return ThumbnailsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return ThumbnailsPigeonCodecWriter(data: data) + } +} + +class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol ThumbnailApi { + func getThumbnail(assetId: String, width: Int64, height: Int64, completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class ThumbnailApiSetup { + static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared } + /// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let getThumbnailChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbnail\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getThumbnailChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let assetIdArg = args[0] as! String + let widthArg = args[1] as! Int64 + let heightArg = args[2] as! Int64 + api.getThumbnail(assetId: assetIdArg, width: widthArg, height: heightArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getThumbnailChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol PlatformThumbnailApiProtocol { + func getThumbnail(assetId assetIdArg: String, width widthArg: Int64, height heightArg: Int64, completion: @escaping (Result) -> Void) +} +class PlatformThumbnailApi: PlatformThumbnailApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: ThumbnailsPigeonCodec { + return ThumbnailsPigeonCodec.shared + } + func getThumbnail(assetId assetIdArg: String, width widthArg: Int64, height heightArg: Int64, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([assetIdArg, widthArg, heightArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + let result: FlutterStandardTypedData? = nilOrValue(listResponse[0]) + completion(.success(result)) + } + } + } +} diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift new file mode 100644 index 0000000000..937d021e68 --- /dev/null +++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift @@ -0,0 +1,151 @@ +import CryptoKit +import Flutter +import Photos +import MobileCoreServices + +// https://stackoverflow.com/a/55839062 +extension UIImage { + func toData (options: NSDictionary?, type: ImageType) -> Data? { + guard cgImage != nil else { return nil } + return toData(options: options, type: type.value) + } + + // about properties: https://developer.apple.com/documentation/imageio/1464962-cgimagedestinationaddimage + func toData (options: NSDictionary?, type: CFString) -> Data? { + guard let cgImage = cgImage else { return nil } + return autoreleasepool { () -> Data? in + let data = NSMutableData() + guard let imageDestination = CGImageDestinationCreateWithData(data as CFMutableData, type, 1, nil) else { return nil } + CGImageDestinationAddImage(imageDestination, cgImage, options) + CGImageDestinationFinalize(imageDestination) + return data as Data + } + } + + enum ImageType { + case image // abstract image data + case jpeg // JPEG image + case jpeg2000 // JPEG-2000 image + case tiff // TIFF image + case pict // Quickdraw PICT format + case gif // GIF image + case png // PNG image + case quickTimeImage // QuickTime image format (OSType 'qtif') + case appleICNS // Apple icon data + case bmp // Windows bitmap + case ico // Windows icon data + case rawImage // base type for raw image data (.raw) + case scalableVectorGraphics // SVG image + case livePhoto // Live Photo + + var value: CFString { + switch self { + case .image: return kUTTypeImage + case .jpeg: return kUTTypeJPEG + case .jpeg2000: return kUTTypeJPEG2000 + case .tiff: return kUTTypeTIFF + case .pict: return kUTTypePICT + case .gif: return kUTTypeGIF + case .png: return kUTTypePNG + case .quickTimeImage: return kUTTypeQuickTimeImage + case .appleICNS: return kUTTypeAppleICNS + case .bmp: return kUTTypeBMP + case .ico: return kUTTypeICO + case .rawImage: return kUTTypeRawImage + case .scalableVectorGraphics: return kUTTypeScalableVectorGraphics + case .livePhoto: return kUTTypeLivePhoto + } + } + } +} + +class ThumbnailApiImpl: ThumbnailApi { + private static let cacheManager = PHImageManager.default() + private static let fetchOptions = { + let fetchOptions = PHFetchOptions() + fetchOptions.fetchLimit = 1 + return fetchOptions + }() + private static let requestOptions = { + let requestOptions = PHImageRequestOptions() + requestOptions.isNetworkAccessAllowed = true + requestOptions.deliveryMode = .highQualityFormat + requestOptions.resizeMode = .exact + requestOptions.isSynchronous = true + requestOptions.version = .current + return requestOptions + }() + private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) + private static let imageCache = NSCache() + + func requestThumbnail( + assetId: String, + width: Int64, + height: Int64, + completion: @escaping (Result) -> Void + ) { + Self.processingQueue.async { + do { + let asset = try self.getAsset(assetId: assetId) + + let requestId = Self.cacheManager.requestImage( + for: asset, + targetSize: CGSize(width: Double(width), height: Double(height)), + contentMode: .aspectFill, + options: Self.requestOptions, + resultHandler: { (image, info) -> Void in + guard let data = image?.toData(options: nil, type: .bmp) else { return } + Self.imageCache.setObject(FlutterStandardTypedData(bytes: data), forKey: assetId as NSString) + } + ) + completion(.success(requestId)) + } catch { + completion(.failure(PigeonError(code: "", message: "Could not get asset data", details: nil))) + } + } + } + + func getThumbnail(assetId assetIdArg: String, width widthArg: Int64, height heightArg: Int64, completion: @escaping (Result) -> Void) { + + } + + func sendThumbnail( + assetId: String, + width: Int64, + height: Int64, + completion: @escaping (Result) -> Void + ) { + Self.processingQueue.async { + do { + let asset = try self.getAsset(assetId: assetId) + + Self.cacheManager.requestImage( + for: asset, + targetSize: CGSize(width: Double(width), height: Double(height)), + contentMode: .aspectFill, + options: Self.requestOptions, + resultHandler: { (image, info) -> Void in + guard let data = image?.toData(options: nil, type: .bmp) else { return } + completion(.success(FlutterStandardTypedData(bytes: data))) + } + ) + } catch { + completion(.failure(PigeonError(code: "", message: "Could not get asset data", details: nil))) + } + } + } + + func cancel(requestId: Int32) { + Self.cacheManager.cancelImageRequest(requestId as PHImageRequestID) + } + + private func getAsset(assetId: String) throws -> PHAsset { + guard + let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions) + .firstObject + else { + throw PigeonError(code: "", message: "Could not fetch asset", details: nil) + } + return asset + } +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 4237813dfc..7dbd41b58d 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -1,187 +1,187 @@ - - AppGroupId - $(CUSTOM_GROUP_ID) - BGTaskSchedulerPermittedIdentifiers - - app.alextran.immich.backgroundFetch - app.alextran.immich.backgroundProcessing - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - ${PRODUCT_NAME} - CFBundleDocumentTypes - - - CFBundleTypeName - ShareHandler - LSHandlerRank - Alternate - LSItemContentTypes - - public.file-url - public.image - public.text - public.movie - public.url - public.data - - - - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - ar - ca - cs - da - de - es - fi - fr - he - hi - hu - it - ja - ko - lv - mn - nb - nl - pl - pt - ro - ru - sk - sl - sr - sv - th - uk - vi - zh - - CFBundleName - immich_mobile - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.135.1 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLName - Share Extension - CFBundleURLSchemes - - ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) - - - - CFBundleTypeRole - Editor - CFBundleURLName - Deep Link - CFBundleURLSchemes - - immich - - - - CFBundleVersion - 210 - FLTEnableImpeller - - ITSAppUsesNonExemptEncryption - - LSApplicationQueriesSchemes - - https - - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - No - MGLMapboxMetricsEnabledSettingShownInApp - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _googlecast._tcp - _CC1AD845._googlecast._tcp - - NSCameraUsageDescription - We need to access the camera to let you take beautiful video using this app - NSFaceIDUsageDescription - We need to use FaceID to allow access to your locked folder - NSLocationAlwaysAndWhenInUseUsageDescription - We require this permission to access the local WiFi name for background upload mechanism - NSLocationUsageDescription - We require this permission to access the local WiFi name - NSLocationWhenInUseUsageDescription - We require this permission to access the local WiFi name - NSMicrophoneUsageDescription - We need to access the microphone to let you take beautiful video using this app - NSPhotoLibraryAddUsageDescription - We need to manage backup your photos album - NSPhotoLibraryUsageDescription - We need to manage backup your photos album - NSUserActivityTypes - - INSendMessageIntent - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - fetch - processing - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - io.flutter.embedded_views_preview - - NSLocalNetworkUsageDescription - We need local network permission to connect to the local server using IP address and + + AppGroupId + $(CUSTOM_GROUP_ID) + BGTaskSchedulerPermittedIdentifiers + + app.alextran.immich.backgroundFetch + app.alextran.immich.backgroundProcessing + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + ShareHandler + LSHandlerRank + Alternate + LSItemContentTypes + + public.file-url + public.image + public.text + public.movie + public.url + public.data + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + ar + ca + cs + da + de + es + fi + fr + he + hi + hu + it + ja + ko + lv + mn + nb + nl + pl + pt + ro + ru + sk + sl + sr + sv + th + uk + vi + zh + + CFBundleName + immich_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.135.1 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + Share Extension + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + + CFBundleTypeRole + Editor + CFBundleURLName + Deep Link + CFBundleURLSchemes + + immich + + + + CFBundleVersion + 210 + FLTEnableImpeller + + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + https + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + No + MGLMapboxMetricsEnabledSettingShownInApp + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _googlecast._tcp + _CC1AD845._googlecast._tcp + + NSCameraUsageDescription + We need to access the camera to let you take beautiful video using this app + NSFaceIDUsageDescription + We need to use FaceID to allow access to your locked folder + NSLocalNetworkUsageDescription + We need local network permission to connect to the local server using IP address and allow the casting feature to work - - \ No newline at end of file + NSLocationAlwaysAndWhenInUseUsageDescription + We require this permission to access the local WiFi name for background upload mechanism + NSLocationUsageDescription + We require this permission to access the local WiFi name + NSLocationWhenInUseUsageDescription + We require this permission to access the local WiFi name + NSMicrophoneUsageDescription + We need to access the microphone to let you take beautiful video using this app + NSPhotoLibraryAddUsageDescription + We need to manage backup your photos album + NSPhotoLibraryUsageDescription + We need to manage backup your photos album + NSUserActivityTypes + + INSendMessageIntent + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + processing + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + + + diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index e5862cb213..cfbada638d 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -9,8 +9,6 @@ com.apple.developer.networking.wifi-info com.apple.security.application-groups - - group.app.immich.share - + diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index 6a5c086baf..6a397cfd54 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -11,8 +11,6 @@ com.apple.developer.networking.wifi-info com.apple.security.application-groups - - group.app.immich.share - + diff --git a/mobile/ios/ShareExtension/Info.plist b/mobile/ios/ShareExtension/Info.plist index 0f52fbffdf..dbed75e380 100644 --- a/mobile/ios/ShareExtension/Info.plist +++ b/mobile/ios/ShareExtension/Info.plist @@ -1,35 +1,35 @@ - - AppGroupId - $(CUSTOM_GROUP_ID) - NSExtension - - NSExtensionAttributes - - IntentsSupported - - INSendMessageIntent - - NSExtensionActivationRule - SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, + + AppGroupId + $(CUSTOM_GROUP_ID) + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + NSExtensionActivationRule + SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count > 0 - PHSupportedMediaTypes - - Video - Image - - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.share-services - - - \ No newline at end of file + PHSupportedMediaTypes + + Video + Image + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/mobile/ios/ShareExtension/ShareExtension.entitlements b/mobile/ios/ShareExtension/ShareExtension.entitlements index 4ad1a257d8..2eb7e333a6 100644 --- a/mobile/ios/ShareExtension/ShareExtension.entitlements +++ b/mobile/ios/ShareExtension/ShareExtension.entitlements @@ -3,8 +3,6 @@ com.apple.security.application-groups - - group.app.immich.share - + diff --git a/mobile/ios/WidgetExtension/WidgetExtension.entitlements b/mobile/ios/WidgetExtension/WidgetExtension.entitlements index 4ad1a257d8..2eb7e333a6 100644 --- a/mobile/ios/WidgetExtension/WidgetExtension.entitlements +++ b/mobile/ios/WidgetExtension/WidgetExtension.entitlements @@ -3,8 +3,6 @@ com.apple.security.application-groups - - group.app.immich.share - + diff --git a/mobile/lib/platform/thumbnail_api.g.dart b/mobile/lib/platform/thumbnail_api.g.dart new file mode 100644 index 0000000000..6158a4755d --- /dev/null +++ b/mobile/lib/platform/thumbnail_api.g.dart @@ -0,0 +1,150 @@ +// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse( + {Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class ThumbnailApi { + /// Constructor for [ThumbnailApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + ThumbnailApi( + {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future getThumbnail( + String assetId, { + required int width, + required int height, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbnail$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([assetId, width, height]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as Uint8List?)!; + } + } +} + +abstract class PlatformThumbnailApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + Future getThumbnail(String assetId, int width, int height); + + static void setUp( + PlatformThumbnailApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail was null.'); + final List args = (message as List?)!; + final String? arg_assetId = (args[0] as String?); + assert(arg_assetId != null, + 'Argument for dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail was null, expected non-null String.'); + final int? arg_width = (args[1] as int?); + assert(arg_width != null, + 'Argument for dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail was null, expected non-null int.'); + final int? arg_height = (args[2] as int?); + assert(arg_height != null, + 'Argument for dev.flutter.pigeon.immich_mobile.PlatformThumbnailApi.getThumbnail was null, expected non-null int.'); + try { + final Uint8List? output = + await api.getThumbnail(arg_assetId!, arg_width!, arg_height!); + return wrapResponse(result: output); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index bac23b45c7..45cd087961 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { final asset = selectedAssets.elementAt(index); return GestureDetector( onTap: onBackgroundTapped, - child: Thumbnail(asset: asset), + child: Thumbnail.fromBaseAsset(asset: asset), ); }, childCount: selectedAssets.length), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 2fa54ad65d..7e99e15851 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -482,7 +482,7 @@ class _AssetViewerState extends ConsumerState { width: double.infinity, height: double.infinity, color: backgroundColor, - child: Thumbnail(asset: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)), + child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)), ); } @@ -532,7 +532,7 @@ class _AssetViewerState extends ConsumerState { width: ctx.width, height: ctx.height, color: backgroundColor, - child: Thumbnail(asset: asset, fit: BoxFit.contain, size: size), + child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain, size: size), ), ); } diff --git a/mobile/lib/presentation/widgets/images/full_image.widget.dart b/mobile/lib/presentation/widgets/images/full_image.widget.dart index b6bd06e05f..29ba0f6b70 100644 --- a/mobile/lib/presentation/widgets/images/full_image.widget.dart +++ b/mobile/lib/presentation/widgets/images/full_image.widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:octo_image/octo_image.dart'; class FullImage extends StatelessWidget { @@ -9,7 +9,7 @@ class FullImage extends StatelessWidget { this.asset, { required this.size, this.fit = BoxFit.cover, - this.placeholder = const Thumbhash(), + this.placeholder = const Thumbnail(), super.key, }); diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 4cb22eeefa..94184c27f5 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -1,53 +1,402 @@ import 'package:flutter/material.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui' as ui; +import 'dart:ui'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:logging/logging.dart'; -import 'package:octo_image/octo_image.dart'; +import 'package:thumbhash/thumbhash.dart' as thumbhash; -class Thumbnail extends StatelessWidget { - const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key}) - : assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); +final log = Logger('ThumbnailWidget'); - final BaseAsset? asset; - final String? remoteId; - final Size size; +class Thumbnail extends StatefulWidget { final BoxFit fit; + final Size size; + final String? blurhash; + final String? localId; + final String? remoteId; + final bool thumbhashOnly; + + const Thumbnail({ + this.fit = BoxFit.cover, + this.size = const Size.square(256), + this.blurhash, + this.localId, + this.remoteId, + this.thumbhashOnly = false, + super.key, + }); + + Thumbnail.fromAsset({ + required Asset asset, + this.fit = BoxFit.cover, + this.size = const Size.square(256), + this.thumbhashOnly = false, + super.key, + }) : blurhash = asset.thumbhash, + localId = asset.localId, + remoteId = asset.remoteId; + + Thumbnail.fromBaseAsset({ + required BaseAsset? asset, + this.fit = BoxFit.cover, + this.size = const Size.square(256), + this.thumbhashOnly = false, + super.key, + }) : blurhash = switch (asset) { + RemoteAsset() => asset.thumbHash, + _ => null, + }, + localId = switch (asset) { + RemoteAsset() => asset.localId, + LocalAsset() => asset.id, + _ => null, + }, + remoteId = switch (asset) { + RemoteAsset() => asset.id, + LocalAsset() => asset.remoteId, + _ => null, + }; + + @override + State createState() => _ThumbnailState(); +} + +class _ThumbnailState extends State { + ui.Image? _image; + + static final _gradientCache = {}; + static final _imageCache = ThumbnailImageCacheManager(); + + @override + void initState() { + super.initState(); + _decode(); + } + + @override + void didUpdateWidget(Thumbnail oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.blurhash != widget.blurhash || + oldWidget.localId != widget.localId || + oldWidget.remoteId != widget.remoteId || + oldWidget.thumbhashOnly != widget.thumbhashOnly) { + _decode(); + } + } + + Future _decode() async { + if (!mounted) { + return; + } + + final thumbhashOnly = widget.thumbhashOnly; + final blurhash = widget.blurhash; + final imageFuture = thumbhashOnly ? Future.value(null) : _decodeFromFile(); + + if (blurhash != null) { + final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash)); + try { + await _decodeThumbhash( + await ImmutableBuffer.fromUint8List(image.rgba), + image.width, + image.height, + ); + } catch (e) { + log.info('Error decoding thumbhash for ${widget.remoteId}: $e'); + } + } + + if (!mounted || thumbhashOnly) { + return; + } + + try { + final image = await imageFuture; + if (!mounted || image == null) { + return; + } + + _image?.dispose(); + setState(() { + _image = image; + }); + } catch (e) { + log.info('Error decoding thumbnail: $e'); + } + } + + Future _decodeThumbhash( + ImmutableBuffer buffer, + int width, + int height, + ) async { + if (!mounted) { + buffer.dispose(); + return; + } + + final descriptor = ImageDescriptor.raw( + buffer, + width: width, + height: height, + pixelFormat: PixelFormat.rgba8888, + ); + if (!mounted) { + buffer.dispose(); + descriptor.dispose(); + return; + } + + final codec = await descriptor.instantiateCodec( + targetWidth: width, + targetHeight: height, + ); + + if (!mounted) { + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + return; + } + + final frame = (await codec.getNextFrame()).image; + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + if (!mounted) { + frame.dispose(); + return; + } + setState(() { + _image = frame; + }); + } + + Future _decodeFromFile() async { + final buffer = await _getFile(); + if (buffer == null) { + return null; + } + final stopwatch = Stopwatch()..start(); + final thumb = await _decodeThumbnail(buffer, 256); + stopwatch.stop(); + return thumb; + } + + Future _decodeThumbnail(ImmutableBuffer buffer, int height) async { + if (!mounted) { + buffer.dispose(); + return null; + } + + final descriptor = await ImageDescriptor.encoded(buffer); + if (!mounted) { + buffer.dispose(); + descriptor.dispose(); + return null; + } + + final codec = await descriptor.instantiateCodec(targetHeight: height); + + if (!mounted) { + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + return null; + } + + final frame = (await codec.getNextFrame()).image; + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + if (!mounted) { + frame.dispose(); + return null; + } + + return frame; + } + + Future _getFile() async { + final stopwatch = Stopwatch()..start(); + final localId = widget.localId; + if (localId != null) { + try { + final data = + await thumbnailApi.getThumbnail(localId, width: 256, height: 256); + stopwatch.stop(); + log.info( + 'Retrieved local image $localId in ${stopwatch.elapsedMilliseconds.toStringAsFixed(2)} ms', + ); + return ImmutableBuffer.fromUint8List(data); + } catch (e) { + log.warning('Failed to retrieve local image $localId: $e'); + } + } + + final remoteId = widget.remoteId; + if (remoteId != null) { + final uri = getThumbnailUrlForRemoteId(remoteId); + final headers = ApiService.getRequestHeaders(); + final stream = _imageCache.getFileStream( + uri, + key: uri, + withProgress: true, + headers: headers, + ); + + await for (final result in stream) { + if (!mounted) { + return null; + } + + if (result is FileInfo) { + stopwatch.stop(); + log.info( + 'Retrieved remote image $remoteId in ${stopwatch.elapsedMilliseconds.toStringAsFixed(2)} ms', + ); + return ImmutableBuffer.fromFilePath(result.file.path); + } + } + } + + return null; + } @override Widget build(BuildContext context) { - final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; - final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size); - - return OctoImage.fromSet( - image: provider, - octoSet: OctoSet( - placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit), - errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset), - ), - fadeOutDuration: const Duration(milliseconds: 100), - fadeInDuration: Duration.zero, - width: size.width, - height: size.height, - fit: fit, - placeholderFadeInDuration: Duration.zero, + final colorScheme = context.colorScheme; + final gradient = _gradientCache[colorScheme] ??= LinearGradient( + colors: [ + colorScheme.surfaceContainer, + colorScheme.surfaceContainer.darken(amount: .1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ); + + return _ThumbhashLeaf( + image: _image, + fit: widget.fit, + placeholderGradient: gradient, + ); + } + + @override + void dispose() { + _image?.dispose(); + super.dispose(); } } -OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) { - return (context) => Thumbhash(blurhash: thumbHash, fit: fit ?? BoxFit.cover); +class _ThumbhashLeaf extends LeafRenderObjectWidget { + final ui.Image? image; + final BoxFit fit; + final Gradient placeholderGradient; + + const _ThumbhashLeaf({ + required this.image, + required this.fit, + required this.placeholderGradient, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _ThumbhashRenderBox( + image: image, + fit: fit, + placeholderGradient: placeholderGradient, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _ThumbhashRenderBox renderObject, + ) { + renderObject.fit = fit; + renderObject.image = image; + renderObject.placeholderGradient = placeholderGradient; + } } -OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) => - (context, e, s) { - Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s); - provider?.evict(); - return Stack( - alignment: Alignment.center, - children: [ - _blurHashPlaceholderBuilder(blurhash, fit: fit)(context), - const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)), - ], - ); - }; +class _ThumbhashRenderBox extends RenderBox { + ui.Image? _image; + BoxFit _fit; + Gradient _placeholderGradient; + + @override + bool isRepaintBoundary = true; + + _ThumbhashRenderBox({ + required ui.Image? image, + required BoxFit fit, + required Gradient placeholderGradient, + }) : _image = image, + _fit = fit, + _placeholderGradient = placeholderGradient; + + @override + void paint(PaintingContext context, Offset offset) { + final image = _image; + final rect = offset & size; + if (image == null) { + final paint = Paint(); + paint.shader = _placeholderGradient.createShader(rect); + context.canvas.drawRect(rect, paint); + return; + } + + paintImage( + canvas: context.canvas, + rect: rect, + image: image, + fit: _fit, + filterQuality: FilterQuality.low, + ); + } + + @override + void performLayout() { + size = constraints.biggest; + } + + set image(ui.Image? value) { + if (_image != value) { + _image = value; + markNeedsPaint(); + } + } + + set fit(BoxFit value) { + if (_fit == value) { + return; + } + + _fit = value; + if (_image != null) { + markNeedsPaint(); + } + } + + set placeholderGradient(Gradient value) { + if (_placeholderGradient == value) { + return; + } + + _placeholderGradient = value; + if (_image == null) { + markNeedsPaint(); + } + } +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index b9ef1ca45a..3053480b7f 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -19,7 +19,7 @@ class ThumbnailTile extends ConsumerWidget { super.key, }); - final BaseAsset asset; + final BaseAsset? asset; final Size size; final BoxFit fit; final bool showStorageIndicator; @@ -28,6 +28,7 @@ class ThumbnailTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final asset = this.asset; final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final assetContainerColor = context.isDarkTheme @@ -50,7 +51,7 @@ class ThumbnailTile extends ConsumerWidget { ) : const BoxDecoration(); - final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null; + final hasStack = asset is RemoteAsset && asset.stackId != null; return Stack( children: [ @@ -66,8 +67,8 @@ class ThumbnailTile extends ConsumerWidget { children: [ Positioned.fill( child: Hero( - tag: '${asset.heroTag}_$heroIndex', - child: Thumbnail(asset: asset, fit: fit, size: size), + tag: '${asset?.heroTag ?? ''}_$heroIndex', + child: Thumbnail.fromBaseAsset(asset: asset, fit: fit, size: size), ), ), if (hasStack) @@ -78,7 +79,7 @@ class ThumbnailTile extends ConsumerWidget { child: const _TileOverlayIcon(Icons.burst_mode_rounded), ), ), - if (asset.isVideo) + if (asset != null && asset.isVideo) Align( alignment: Alignment.topRight, child: Padding( @@ -86,7 +87,7 @@ class ThumbnailTile extends ConsumerWidget { child: _VideoIndicator(asset.duration), ), ), - if (showStorageIndicator) + if (showStorageIndicator && asset != null) switch (asset.storage) { AssetState.local => const Align( alignment: Alignment.bottomRight, @@ -110,7 +111,7 @@ class ThumbnailTile extends ConsumerWidget { ), ), }, - if (asset.isFavorite) + if (asset != null && asset.isFavorite) const Align( alignment: Alignment.bottomLeft, child: Padding( diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index eba0a3bae2..ec3c370222 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; class DriftMemoryCard extends StatelessWidget { final RemoteAsset asset; @@ -91,7 +91,7 @@ class _BlurredBackdrop extends HookWidget { final blurhash = asset.thumbHash; if (blurhash != null) { // Use a nice cheap blur hash image decoration - return Thumbhash(blurhash: blurhash, fit: BoxFit.cover); + return Thumbnail(blurhash: blurhash); } // Fall back to using a more expensive image filtered diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index e2bc59b4c7..48d4f25873 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -57,12 +57,15 @@ class DriftMemoryCard extends ConsumerWidget { child: Stack( children: [ ColorFiltered( - colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken), - child: SizedBox( - width: 205, - height: 200, - child: Thumbnail(remoteId: memory.assets[0].id, fit: BoxFit.cover), + colorFilter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.darken, ), + child: SizedBox( + width: 205, + height: 200, + child: Thumbnail.fromBaseAsset(asset: memory.assets[0]), + ), ), Positioned( bottom: 16, diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index fece4cc580..06bd1975b0 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -10,8 +10,6 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget. import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -108,42 +106,41 @@ class _FixedSegmentRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final timelineService = ref.read(timelineServiceProvider); - if (isScrubbing) { - return _buildPlaceholder(context); - } - if (timelineService.hasRange(assetIndex, assetCount)) { return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); } - return FutureBuilder>( - future: timelineService.loadAssets(assetIndex, assetCount), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return _buildPlaceholder(context); - } - return _buildAssetRow(context, snapshot.requireData, timelineService); + try { + final assets = timelineService.getAssets(assetIndex, assetCount); + return FutureBuilder>( + future: null, + initialData: assets, + builder: (context, snapshot) { + return _buildAssetRow(context, snapshot.data, timelineService); + }, + ); + } catch (e) { + return FutureBuilder>( + future: timelineService.loadAssets(assetIndex, assetCount), + builder: (context, snapshot) { + return _buildAssetRow(context, snapshot.data, timelineService); }, ); + } } - Widget _buildPlaceholder(BuildContext context) { - return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing); - } - - Widget _buildAssetRow(BuildContext context, List assets, TimelineService timelineService) { + Widget _buildAssetRow(BuildContext context, List? assets, TimelineService timelineService) { return FixedTimelineRow( dimension: tileHeight, spacing: spacing, textDirection: Directionality.of(context), children: [ - for (int i = 0; i < assets.length; i++) + for (int i = 0; i < assetCount; i++) _AssetTileWidget( - key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), - asset: assets[i], + key: ValueKey(i.hashCode ^ (assetIndex + i).hashCode ^ timelineService.hashCode), + asset: assets == null ? null : assets[i], assetIndex: assetIndex + i, ), ], @@ -152,7 +149,7 @@ class _FixedSegmentRow extends ConsumerWidget { } class _AssetTileWidget extends ConsumerWidget { - final BaseAsset asset; + final BaseAsset? asset; final int assetIndex; const _AssetTileWidget({super.key, required this.asset, required this.assetIndex}); @@ -201,17 +198,18 @@ class _AssetTileWidget extends ConsumerWidget { final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); + final asset = this.asset; - return RepaintBoundary( - child: GestureDetector( - onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), - onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset), + return GestureDetector( + onTap: () => lockSelection || asset == null ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), + onLongPress: () => lockSelection || asset == null + ? null + : _handleOnLongPress(ref, asset), child: ThumbnailTile( asset, lockSelection: lockSelection, showStorageIndicator: showStorageIndicator, heroOffset: heroOffset, - ), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index 77a3ce4c05..7c4c87a503 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -1,8 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; abstract class SegmentBuilder { final List buckets; @@ -29,7 +29,7 @@ abstract class SegmentBuilder { dimension: size.height, spacing: spacing, textDirection: Directionality.of(context), - children: List.filled(count, const Thumbhash()), + children: List.filled(count, const Thumbnail()), ), ); } diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 477046d0bf..6469624c09 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,4 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/thumbnail_api.g.dart'; final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); + +final thumbnailApi = ThumbnailApi(); diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 7eaedd27b5..8ce902b161 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -40,9 +40,9 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( - label: Container( - decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(widgetSize / 2)), - child: const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: widgetSize / 2), + label: const DecoratedBox( + decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.all(Radius.circular(widgetSize / 2))), + child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: widgetSize / 2), ), backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index f404368f05..401baa7b21 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -3,9 +3,9 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:octo_image/octo_image.dart'; class ImmichImage extends StatelessWidget { @@ -14,7 +14,7 @@ class ImmichImage extends StatelessWidget { this.width, this.height, this.fit = BoxFit.cover, - this.placeholder = const Thumbhash(), + this.placeholder = const Thumbnail(), super.key, }); diff --git a/mobile/lib/widgets/common/thumbhash.dart b/mobile/lib/widgets/common/thumbhash.dart deleted file mode 100644 index a429e56433..0000000000 --- a/mobile/lib/widgets/common/thumbhash.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:ui' as ui; -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:thumbhash/thumbhash.dart' as thumbhash; - - -class Thumbhash extends StatefulWidget { - final String? blurhash; - final BoxFit fit; - - const Thumbhash({ - this.blurhash, - this.fit = BoxFit.cover, - super.key, - }); - - @override - State createState() => _ThumbhashState(); -} - - -class _ThumbhashState extends State { - String? blurhash; - BoxFit? fit; - ui.Image? _image; - - static final _gradientCache = {}; - - @override - void initState() { - super.initState(); - final blurhash_ = blurhash = widget.blurhash; - fit = widget.fit; - if (blurhash_ == null) { - return; - } - final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash_)); - _decode(image); - } - - Future _decode(thumbhash.Image image) async { - if (!mounted) { - return; - } - final buffer = await ImmutableBuffer.fromUint8List(image.rgba); - if (!mounted) { - buffer.dispose(); - return; - } - - final descriptor = ImageDescriptor.raw( - buffer, - width: image.width, - height: image.height, - pixelFormat: PixelFormat.rgba8888, - ); - if (!mounted) { - buffer.dispose(); - descriptor.dispose(); - return; - } - - final codec = await descriptor.instantiateCodec( - targetWidth: image.width, - targetHeight: image.height, - ); - if (!mounted) { - buffer.dispose(); - descriptor.dispose(); - codec.dispose(); - return; - } - - final frame = (await codec.getNextFrame()).image; - buffer.dispose(); - descriptor.dispose(); - codec.dispose(); - if (!mounted) { - frame.dispose(); - return; - } - setState(() { - _image = frame; - }); - } - - @override - Widget build(BuildContext context) { - final colorScheme = context.colorScheme; - final gradient = _gradientCache[colorScheme] ??= LinearGradient( - colors: [ - colorScheme.surfaceContainer, - colorScheme.surfaceContainer.darken(amount: .1), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); - - return _ThumbhashLeaf( - image: _image, - fit: fit!, - placeholderGradient: gradient, - ); - } - - @override - void dispose() { - _image?.dispose(); - super.dispose(); - } -} - -class _ThumbhashLeaf extends LeafRenderObjectWidget { - final ui.Image? image; - final BoxFit fit; - final Gradient placeholderGradient; - - const _ThumbhashLeaf({ - required this.image, - required this.fit, - required this.placeholderGradient, - }); - - @override - RenderObject createRenderObject(BuildContext context) { - return _ThumbhashRenderBox( - image: image, - fit: fit, - placeholderGradient: placeholderGradient, - ); - } - - @override - void updateRenderObject( - BuildContext context, - _ThumbhashRenderBox renderObject, - ) { - renderObject.fit = fit; - renderObject.image = image; - renderObject.placeholderGradient = placeholderGradient; - } -} - -class _ThumbhashRenderBox extends RenderBox { - ui.Image? _image; - BoxFit _fit; - Gradient _placeholderGradient; - - _ThumbhashRenderBox({ - required ui.Image? image, - required BoxFit fit, - required Gradient placeholderGradient, - }) : _image = image, - _fit = fit, - _placeholderGradient = placeholderGradient; - - @override - void paint(PaintingContext context, Offset offset) { - final image = _image; - final rect = offset & size; - if (image == null) { - final paint = Paint(); - paint.shader = _placeholderGradient.createShader(rect); - context.canvas.drawRect(rect, paint); - return; - } - - paintImage( - canvas: context.canvas, - rect: rect, - image: image, - fit: _fit, - filterQuality: FilterQuality.low, - ); - } - - @override - void performLayout() { - size = constraints.biggest; - } - - set image(ui.Image? value) { - if (_image != value) { - _image = value; - markNeedsPaint(); - } - } - - set fit(BoxFit value) { - if (_fit != value) { - _fit = value; - markNeedsPaint(); - } - } - - set placeholderGradient(Gradient value) { - if (_placeholderGradient != value) { - _placeholderGradient = value; - markNeedsPaint(); - } - } -} diff --git a/mobile/lib/widgets/common/thumbhash_placeholder.dart b/mobile/lib/widgets/common/thumbhash_placeholder.dart index fcd569132c..d72a5146d1 100644 --- a/mobile/lib/widgets/common/thumbhash_placeholder.dart +++ b/mobile/lib/widgets/common/thumbhash_placeholder.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:octo_image/octo_image.dart'; OctoPlaceholderBuilder blurHashPlaceholderBuilder(String? blurhash, {required BoxFit fit}) { - return (context) => Thumbhash(blurhash: blurhash, fit: fit); + return (context) => Thumbnail(blurhash: blurhash, fit: fit); } OctoErrorBuilder blurHashErrorBuilder( diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 828f18871f..2d88f98845 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; -import 'package:immich_mobile/widgets/common/thumbhash.dart'; class MemoryCard extends StatelessWidget { final Asset asset; @@ -90,7 +90,7 @@ class _BlurredBackdrop extends HookWidget { final blurhash = asset.thumbhash; if (blurhash != null) { // Use a nice cheap blur hash image decoration - return Thumbhash(blurhash: blurhash, fit: BoxFit.cover); + return Thumbnail(blurhash: blurhash, fit: BoxFit.cover); } // Fall back to using a more expensive image filtered diff --git a/mobile/makefile b/mobile/makefile index 356649d5dd..45f7e3cd99 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -7,7 +7,9 @@ build: pigeon: dart run pigeon --input pigeon/native_sync_api.dart + dart run pigeon --input pigeon/thumbnail_api.dart dart format lib/platform/native_sync_api.g.dart + dart format lib/platform/thumbnail_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/thumbnail_api.dart b/mobile/pigeon/thumbnail_api.dart new file mode 100644 index 0000000000..7273365e1b --- /dev/null +++ b/mobile/pigeon/thumbnail_api.dart @@ -0,0 +1,37 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/thumbnail_api.g.dart', + swiftOut: 'ios/Runner/Images/Thumbnails.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class ThumbnailApi { + @async + Uint8List getThumbnail( + String assetId, { + required int width, + required int height, + }); + + // @async + // int requestThumbnail( + // String assetId, { + // required int width, + // required int height, + // void Function(int requestId) onDone, + // }); +} + +@FlutterApi() +abstract class PlatformThumbnailApi { + @async + Uint8List? getThumbnail(String assetId, int width, int height); +}