From 4be13ffbbd3d60accebb2f6b41b9343c868bd8d6 Mon Sep 17 00:00:00 2001 From: juanjsebgarcia Date: Thu, 8 Jan 2026 14:14:27 +0000 Subject: [PATCH 1/2] feat: add colour filtering to immich frame settings Signed-off-by: juanjsebgarcia --- Makefile | 58 ++++ .../com/immichframe/immichframe/Helpers.kt | 265 +++++++++++++++++- .../immichframe/immichframe/MainActivity.kt | 45 +++ .../immichframe/ScreenSaverService.kt | 45 +++ .../immichframe/SeekBarPreference.kt | 151 ++++++++++ .../immichframe/SettingsFragment.kt | 44 +++ .../immichframe/immichframe/WidgetProvider.kt | 1 + .../main/res/layout/preference_seekbar.xml | 58 ++++ app/src/main/res/values/attrs.xml | 7 + app/src/main/res/xml/settings_view.xml | 47 +++- 10 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 Makefile create mode 100644 app/src/main/java/com/immichframe/immichframe/SeekBarPreference.kt create mode 100644 app/src/main/res/layout/preference_seekbar.xml create mode 100644 app/src/main/res/values/attrs.xml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5b005ff --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +.PHONY: build clean install install-debug install-release test help + +# Default target +help: + @echo "Available targets:" + @echo " build - Build debug APK" + @echo " build-release - Build release APK" + @echo " clean - Clean build artifacts" + @echo " install - Install debug APK to connected device" + @echo " install-release- Install release APK to connected device" + @echo " uninstall - Uninstall app from connected device" + @echo " test - Run tests" + @echo " lint - Run lint checks" + @echo " assemble - Build all variants" + +# Build debug APK +build: + ./gradlew assembleDebug + +# Build release APK +build-release: + ./gradlew assembleRelease + +# Build all variants +assemble: + ./gradlew assemble + +# Clean build artifacts +clean: + ./gradlew clean + +# Install debug APK to connected device +install: build + ./gradlew installDebug + +# Install debug APK without building (if already built) +install-debug: + ./gradlew installDebug + +# Install release APK to connected device +install-release: build-release + ./gradlew installRelease + +# Uninstall app from device +uninstall: + ./gradlew uninstallAll + +# Run tests +test: + ./gradlew test + +# Run lint checks +lint: + ./gradlew lint + +# Run app on connected device +run: install + adb shell am start -n com.immichframe.immichframe/.MainActivity diff --git a/app/src/main/java/com/immichframe/immichframe/Helpers.kt b/app/src/main/java/com/immichframe/immichframe/Helpers.kt index 1ff4786..a6d4f6a 100644 --- a/app/src/main/java/com/immichframe/immichframe/Helpers.kt +++ b/app/src/main/java/com/immichframe/immichframe/Helpers.kt @@ -4,8 +4,12 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter import android.graphics.Paint import android.util.Base64 +import android.util.Log +import androidx.preference.PreferenceManager import retrofit2.Call import retrofit2.http.GET import androidx.core.graphics.scale @@ -16,6 +20,12 @@ import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit object Helpers { + // Cache for image adjustment filter to avoid recreating on every image load + // Thread-safe via synchronization + private var cachedFilterSettings: IntArray? = null + private var cachedFilter: ColorMatrixColorFilter? = null + private val filterCacheLock = Any() + fun textSizeMultiplier(context: Context, currentSizeSp: Float, multiplier: Float): Float { val resources = context.resources val fontScale = resources.configuration.fontScale @@ -99,16 +109,36 @@ object Helpers { return BitmapFactory.decodeByteArray(decodedImage, 0, decodedImage.size, options) } + /** + * Reduces bitmap size while maintaining aspect ratio. + * + * IMPORTANT: This function RECYCLES the input bitmap. + * After calling this function, the input bitmap is no longer valid and must not be used. + * + * @param bitmap The bitmap to resize (will be recycled) + * @param maxSize Maximum dimension (width or height) + * @return New resized bitmap + */ fun reduceBitmapQuality(bitmap: Bitmap, maxSize: Int = 1000): Bitmap { val width = bitmap.width val height = bitmap.height - // Calculate new dimensions while maintaining aspect ratio + // If already smaller than maxSize, still create a copy and recycle original for consistency val scaleFactor = maxSize.toFloat() / width.coerceAtLeast(height) - val newWidth = (width * scaleFactor).toInt() - val newHeight = (height * scaleFactor).toInt() - val resizedBitmap = bitmap.scale(newWidth, newHeight) + val resizedBitmap = if (scaleFactor >= 1f) { + // Image is already small enough - create explicit copy to avoid scale() returning same bitmap + val copy = bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false) + bitmap.recycle() + copy + } else { + // Need to scale down + val newWidth = (width * scaleFactor).toInt() + val newHeight = (height * scaleFactor).toInt() + val scaled = bitmap.scale(newWidth, newHeight) + bitmap.recycle() + scaled + } return resizedBitmap } @@ -205,4 +235,231 @@ object Helpers { } } + fun getImageAdjustmentFilter(context: Context, includeGamma: Boolean = true): ColorMatrixColorFilter? { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + // Check if image adjustments are enabled + val adjustmentsEnabled = prefs.getBoolean("imageAdjustments", false) + if (!adjustmentsEnabled) { + synchronized(filterCacheLock) { + cachedFilterSettings = null + cachedFilter = null + } + return null + } + + // Validate and clamp preference values to prevent corruption issues + val brightness = prefs.getInt("image_brightness", 0).coerceIn(-100, 100) + val contrast = prefs.getInt("image_contrast", 0).coerceIn(-100, 100) + val red = prefs.getInt("image_red_channel", 0).coerceIn(-100, 100) + val green = prefs.getInt("image_green_channel", 0).coerceIn(-100, 100) + val blue = prefs.getInt("image_blue_channel", 0).coerceIn(-100, 100) + val gamma = if (includeGamma) prefs.getInt("image_gamma", 100).coerceIn(10, 300) else 100 + + // If all default, return null (no filter needed) + if (brightness == 0 && contrast == 0 && red == 0 && + green == 0 && blue == 0 && gamma == 100) { + synchronized(filterCacheLock) { + cachedFilterSettings = null + cachedFilter = null + } + return null + } + + // Check cache (thread-safe) + // Include includeGamma flag in cache key (use -1 for false, 1 for true) + val gammaKey = if (includeGamma) 1 else -1 + val currentSettings = intArrayOf(brightness, contrast, red, green, blue, gamma, gammaKey) + synchronized(filterCacheLock) { + if (cachedFilterSettings != null && cachedFilterSettings!!.contentEquals(currentSettings)) { + return cachedFilter + } + } + + // Create new filter and cache it + val newFilter = createColorMatrixFilter(brightness, contrast, red, green, blue, gamma) + synchronized(filterCacheLock) { + cachedFilterSettings = currentSettings + cachedFilter = newFilter + } + return newFilter + } + + private fun createColorMatrixFilter( + brightness: Int, + contrast: Int, + red: Int, + green: Int, + blue: Int, + gamma: Int + ): ColorMatrixColorFilter { + val finalMatrix = ColorMatrix() + + // Apply brightness (-100 to +100 range) + if (brightness != 0) { + val brightnessValue = brightness.toFloat() + val brightnessMatrix = ColorMatrix(floatArrayOf( + 1f, 0f, 0f, 0f, brightnessValue, + 0f, 1f, 0f, 0f, brightnessValue, + 0f, 0f, 1f, 0f, brightnessValue, + 0f, 0f, 0f, 1f, 0f + )) + finalMatrix.postConcat(brightnessMatrix) + } + + // Apply contrast (-100 to +100 range) + if (contrast != 0) { + val scale = (100 + contrast) / 100f + val translate = (1 - scale) * 128 + val contrastMatrix = ColorMatrix(floatArrayOf( + scale, 0f, 0f, 0f, translate, + 0f, scale, 0f, 0f, translate, + 0f, 0f, scale, 0f, translate, + 0f, 0f, 0f, 1f, 0f + )) + finalMatrix.postConcat(contrastMatrix) + } + + // Apply RGB channel adjustments (-100 to +100 range as gain/multiplier) + if (red != 0 || green != 0 || blue != 0) { + val redScale = 1f + (red / 100f) + val greenScale = 1f + (green / 100f) + val blueScale = 1f + (blue / 100f) + val rgbMatrix = ColorMatrix(floatArrayOf( + redScale, 0f, 0f, 0f, 0f, + 0f, greenScale, 0f, 0f, 0f, + 0f, 0f, blueScale, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + )) + finalMatrix.postConcat(rgbMatrix) + } + + // Apply gamma (10 to 300 range, representing 0.1 to 3.0) + // NOTE: This is an APPROXIMATION for ColorFilter mode (ImageView/WebView) + // True gamma correction requires non-linear per-pixel transformation + // For widgets (bitmap mode), proper gamma is applied via applyGammaToBitmap() + if (gamma != 100) { + val gammaValue = gamma / 100f + // Better approximation: combine contrast and brightness to approximate gamma curve + // For gamma > 1: increases contrast in midtones (darkens image) + // For gamma < 1: decreases contrast in midtones (lightens image) + val contrastFactor = if (gammaValue > 1f) { + 0.7f + (gammaValue - 1f) * 0.3f // Reduce contrast for darkening + } else { + 1f + (1f - gammaValue) * 0.5f // Increase contrast for lightening + } + val brightnessFactor = if (gammaValue > 1f) { + -(gammaValue - 1f) * 30f // Darken + } else { + (1f - gammaValue) * 40f // Lighten + } + + val gammaMatrix = ColorMatrix(floatArrayOf( + contrastFactor, 0f, 0f, 0f, brightnessFactor, + 0f, contrastFactor, 0f, 0f, brightnessFactor, + 0f, 0f, contrastFactor, 0f, brightnessFactor, + 0f, 0f, 0f, 1f, 0f + )) + finalMatrix.postConcat(gammaMatrix) + } + + return ColorMatrixColorFilter(finalMatrix) + } + + /** + * Applies image adjustments to a bitmap by creating a new bitmap with filters applied. + * + * IMPORTANT: This function ALWAYS RECYCLES the input bitmap to prevent memory leaks. + * After calling this function, the input bitmap is no longer valid and must not be used. + * A new bitmap is always returned, even if no adjustments are applied. + * + * Note: For bitmap mode, gamma correction is applied accurately using per-pixel transformation. + * + * @param bitmap The source bitmap to apply adjustments to (will be recycled) + * @param context Context for accessing SharedPreferences + * @return A new bitmap with adjustments applied (or copy if no adjustments) + */ + fun applyImageAdjustmentsToBitmap(bitmap: Bitmap, context: Context): Bitmap { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val gamma = prefs.getInt("image_gamma", 100).coerceIn(10, 300) + + // Apply ColorMatrix-based adjustments (brightness, contrast, RGB) + // Exclude gamma from ColorFilter - we'll apply it properly via LUT + val filter = getImageAdjustmentFilter(context, includeGamma = false) + val config = bitmap.config ?: Bitmap.Config.ARGB_8888 + var result = Bitmap.createBitmap(bitmap.width, bitmap.height, config) + val canvas = Canvas(result) + val paint = Paint() + + if (filter != null) { + paint.colorFilter = filter + } + + canvas.drawBitmap(bitmap, 0f, 0f, paint) + + // ALWAYS recycle original bitmap to maintain consistent contract + bitmap.recycle() + + // Apply proper gamma correction if needed (per-pixel operation) + if (gamma != 100) { + result = applyGammaToBitmap(result, gamma / 100f) + } + + return result + } + + /** + * Applies proper gamma correction to a bitmap using per-pixel transformation. + * Uses a lookup table for performance. + * + * @param bitmap The bitmap to apply gamma to (will be recycled) + * @param gamma The gamma value (0.1 to 3.0, where 1.0 is no change) + * @return New bitmap with gamma applied + */ + private fun applyGammaToBitmap(bitmap: Bitmap, gamma: Float): Bitmap { + val width = bitmap.width + val height = bitmap.height + + // Safety check: prevent excessive memory allocation + // Max 4000x4000 = 16M pixels = 64MB for ARGB_8888 + val maxPixels = 4000 * 4000 + if (width * height > maxPixels) { + Log.w("Helpers", "Bitmap too large for gamma correction: ${width}x${height}, skipping gamma") + return bitmap + } + + // Build lookup table for gamma correction + val gammaLUT = IntArray(256) { i -> + val normalized = i / 255f + val corrected = Math.pow(normalized.toDouble(), gamma.toDouble()).toFloat() + (corrected * 255f).coerceIn(0f, 255f).toInt() + } + + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + // Apply gamma correction to each pixel + for (i in pixels.indices) { + val pixel = pixels[i] + val a = (pixel shr 24) and 0xff + val r = (pixel shr 16) and 0xff + val g = (pixel shr 8) and 0xff + val b = pixel and 0xff + + val rCorrected = gammaLUT[r] + val gCorrected = gammaLUT[g] + val bCorrected = gammaLUT[b] + + pixels[i] = (a shl 24) or (rCorrected shl 16) or (gCorrected shl 8) or bCorrected + } + + val result = Bitmap.createBitmap(width, height, bitmap.config ?: Bitmap.Config.ARGB_8888) + result.setPixels(pixels, 0, width, 0, 0, width, height) + + // Recycle input bitmap + bitmap.recycle() + + return result + } + } \ No newline at end of file diff --git a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt index ed2b99b..519f9af 100644 --- a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt +++ b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt @@ -6,6 +6,7 @@ import android.annotation.SuppressLint import android.content.Intent import android.graphics.Bitmap import android.graphics.Color +import android.graphics.Paint import android.os.Build import android.os.Bundle import android.os.Handler @@ -236,6 +237,7 @@ class MainActivity : AppCompatActivity() { imageViewNew.scaleX = 1f imageViewNew.scaleY = 1f imageViewNew.setImageBitmap(finalImage) + imageViewNew.colorFilter = Helpers.getImageAdjustmentFilter(this) imageViewNew.visibility = View.VISIBLE if (blurredBackground) { @@ -589,6 +591,23 @@ class MainActivity : AppCompatActivity() { webView.settings.javaScriptEnabled = true webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE webView.settings.domStorageEnabled = true + + // Apply color filter to WebView using layer rendering + val filter = Helpers.getImageAdjustmentFilter(this) + if (filter != null) { + val paint = Paint() + paint.colorFilter = filter + try { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, paint) + } catch (e: Exception) { + // Fallback to software layer if hardware fails + Log.w("MainActivity", "Hardware layer failed, falling back to software: ${e.message}") + webView.setLayerType(View.LAYER_TYPE_SOFTWARE, paint) + } + } else { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + loadWebViewWithRetry(savedUrl) } else { retrofit = Helpers.createRetrofit(savedUrl, authSecret) @@ -597,6 +616,8 @@ class MainActivity : AppCompatActivity() { onSuccess = { settings -> serverSettings = settings onSettingsLoaded() + // Reapply filters to currently visible image + reapplyImageFilters() }, onFailure = { error -> Toast.makeText( @@ -609,6 +630,30 @@ class MainActivity : AppCompatActivity() { } } + private fun reapplyImageFilters() { + if (!useWebView) { + // Apply filter to both ImageViews to prevent transition artifacts + val filter = Helpers.getImageAdjustmentFilter(this) + imageView1.colorFilter = filter + imageView2.colorFilter = filter + } else if (::webView.isInitialized) { + // Reapply filter to WebView (only if initialized) + val filter = Helpers.getImageAdjustmentFilter(this) + if (filter != null) { + val paint = Paint() + paint.colorFilter = filter + try { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, paint) + } catch (e: Exception) { + Log.w("MainActivity", "Hardware layer failed, falling back to software: ${e.message}") + webView.setLayerType(View.LAYER_TYPE_SOFTWARE, paint) + } + } else { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + } + } + private fun onSettingsLoaded() { if (serverSettings.imageFill) { imageView1.scaleType = ImageView.ScaleType.CENTER_CROP diff --git a/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt b/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt index 97cf85e..eef065a 100644 --- a/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt +++ b/app/src/main/java/com/immichframe/immichframe/ScreenSaverService.kt @@ -6,6 +6,7 @@ import android.annotation.SuppressLint import android.content.Intent import android.graphics.Bitmap import android.graphics.Color +import android.graphics.Paint import android.os.Handler import android.os.Looper import android.os.PowerManager @@ -225,6 +226,7 @@ class ScreenSaverService : DreamService() { imageViewNew.scaleX = 1f imageViewNew.scaleY = 1f imageViewNew.setImageBitmap(finalImage) + imageViewNew.colorFilter = Helpers.getImageAdjustmentFilter(this) imageViewNew.visibility = View.VISIBLE if (blurredBackground) { @@ -525,6 +527,23 @@ class ScreenSaverService : DreamService() { webView.settings.javaScriptEnabled = true webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE webView.settings.domStorageEnabled = true + + // Apply color filter to WebView using layer rendering + val filter = Helpers.getImageAdjustmentFilter(this) + if (filter != null) { + val paint = Paint() + paint.colorFilter = filter + try { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, paint) + } catch (e: Exception) { + // Fallback to software layer if hardware fails + Log.w("ScreenSaverService", "Hardware layer failed, falling back to software: ${e.message}") + webView.setLayerType(View.LAYER_TYPE_SOFTWARE, paint) + } + } else { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + loadWebViewWithRetry(savedUrl) } else { retrofit = Helpers.createRetrofit(savedUrl, authSecret) @@ -533,6 +552,8 @@ class ScreenSaverService : DreamService() { onSuccess = { settings -> serverSettings = settings onSettingsLoaded() + // Reapply filters to currently visible image + reapplyImageFilters() }, onFailure = { error -> Toast.makeText( @@ -545,6 +566,30 @@ class ScreenSaverService : DreamService() { } } + private fun reapplyImageFilters() { + if (!useWebView) { + // Apply filter to both ImageViews to prevent transition artifacts + val filter = Helpers.getImageAdjustmentFilter(this) + imageView1.colorFilter = filter + imageView2.colorFilter = filter + } else if (::webView.isInitialized) { + // Reapply filter to WebView (only if initialized) + val filter = Helpers.getImageAdjustmentFilter(this) + if (filter != null) { + val paint = Paint() + paint.colorFilter = filter + try { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, paint) + } catch (e: Exception) { + Log.w("ScreenSaverService", "Hardware layer failed, falling back to software: ${e.message}") + webView.setLayerType(View.LAYER_TYPE_SOFTWARE, paint) + } + } else { + webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + } + } + private fun onSettingsLoaded() { if (serverSettings.imageFill){ imageView1.scaleType = ImageView.ScaleType.CENTER_CROP diff --git a/app/src/main/java/com/immichframe/immichframe/SeekBarPreference.kt b/app/src/main/java/com/immichframe/immichframe/SeekBarPreference.kt new file mode 100644 index 0000000..bd04241 --- /dev/null +++ b/app/src/main/java/com/immichframe/immichframe/SeekBarPreference.kt @@ -0,0 +1,151 @@ +package com.immichframe.immichframe + +import android.content.Context +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.widget.SeekBar +import android.widget.TextView +import androidx.preference.DialogPreference +import androidx.preference.PreferenceDialogFragmentCompat + +class SeekBarPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle +) : DialogPreference(context, attrs, defStyleAttr) { + + var min: Int = 0 + var max: Int = 100 + var currentValue: Int = 0 + + init { + // Read custom attributes from XML + attrs?.let { + val typedArray = context.obtainStyledAttributes(it, R.styleable.SeekBarPreference) + min = typedArray.getInt(R.styleable.SeekBarPreference_min, 0) + max = typedArray.getInt(R.styleable.SeekBarPreference_max, 100) + typedArray.recycle() + } + + dialogLayoutResource = R.layout.preference_seekbar + } + + override fun onGetDefaultValue(a: android.content.res.TypedArray, index: Int): Any { + return a.getInt(index, 0) + } + + override fun onSetInitialValue(defaultValue: Any?) { + currentValue = getPersistedInt((defaultValue as? Int) ?: 0) + updateSummary() + } + + fun saveValue(value: Int) { + currentValue = value + persistInt(value) + updateSummary() + } + + private fun updateSummary() { + summary = if (key == "image_gamma") { + String.format("%.2f", currentValue / 100.0) + } else { + currentValue.toString() + } + } + + class SeekBarPreferenceDialogFragment : PreferenceDialogFragmentCompat() { + private var seekBar: SeekBar? = null + private var valueText: TextView? = null + private var minText: TextView? = null + private var maxText: TextView? = null + private var resetButton: android.widget.Button? = null + + override fun onBindDialogView(view: View) { + super.onBindDialogView(view) + + val preference = preference as? SeekBarPreference ?: run { + // Safety check: if preference is not SeekBarPreference, log error and return + Log.e("SeekBarPreference", "Dialog bound to non-SeekBarPreference") + return + } + seekBar = view.findViewById(R.id.seekbar) + valueText = view.findViewById(R.id.seekbar_value) + minText = view.findViewById(R.id.seekbar_min) + maxText = view.findViewById(R.id.seekbar_max) + resetButton = view.findViewById(R.id.reset_button) + + // Set min/max labels + val isGamma = preference.key == "image_gamma" + minText?.text = if (isGamma) { + String.format("%.1f", preference.min / 100.0) + } else { + preference.min.toString() + } + maxText?.text = if (isGamma) { + String.format("%.1f", preference.max / 100.0) + } else { + preference.max.toString() + } + + seekBar?.apply { + max = preference.max - preference.min + progress = preference.currentValue - preference.min + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged( + seekBar: SeekBar?, + progress: Int, + fromUser: Boolean + ) { + val value = progress + preference.min + updateValueText(value, isGamma) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + + // Set up reset button + resetButton?.setOnClickListener { + // Default value: 0 for most, 100 for gamma + val defaultValue = if (isGamma) 100 else 0 + seekBar?.progress = defaultValue - preference.min + updateValueText(defaultValue, isGamma) + } + + updateValueText(preference.currentValue, isGamma) + } + + private fun updateValueText(value: Int, isGamma: Boolean) { + valueText?.text = if (isGamma) { + String.format("%.2f", value / 100.0) + } else { + value.toString() + } + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (positiveResult) { + val preference = preference as? SeekBarPreference ?: run { + Log.e("SeekBarPreference", "Dialog closed for non-SeekBarPreference") + return + } + val value = (seekBar?.progress ?: 0) + preference.min + if (preference.callChangeListener(value)) { + preference.saveValue(value) + } + } + } + + companion object { + fun newInstance(key: String): SeekBarPreferenceDialogFragment { + val fragment = SeekBarPreferenceDialogFragment() + val bundle = android.os.Bundle(1) + bundle.putString(ARG_KEY, key) + fragment.arguments = bundle + return fragment + } + } + } +} diff --git a/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt b/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt index d2f52ad..23c9957 100644 --- a/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt +++ b/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt @@ -15,6 +15,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.SwitchPreferenceCompat +import androidx.fragment.app.DialogFragment class SettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -24,6 +25,13 @@ class SettingsFragment : PreferenceFragmentCompat() { val chkShowCurrentDate = findPreference("showCurrentDate") val chkScreenDim = findPreference("screenDim") val txtDimTime = findPreference("dim_time_range") + val chkImageAdjustments = findPreference("imageAdjustments") + val imageBrightness = findPreference("image_brightness") + val imageContrast = findPreference("image_contrast") + val imageRedChannel = findPreference("image_red_channel") + val imageGreenChannel = findPreference("image_green_channel") + val imageBlueChannel = findPreference("image_blue_channel") + val imageGamma = findPreference("image_gamma") //obfuscate the authSecret @@ -38,6 +46,13 @@ class SettingsFragment : PreferenceFragmentCompat() { chkShowCurrentDate?.isVisible = !useWebView val screenDim = chkScreenDim?.isChecked ?: false txtDimTime?.isVisible = screenDim + val imageAdjustments = chkImageAdjustments?.isChecked ?: false + imageBrightness?.isVisible = imageAdjustments + imageContrast?.isVisible = imageAdjustments + imageRedChannel?.isVisible = imageAdjustments + imageGreenChannel?.isVisible = imageAdjustments + imageBlueChannel?.isVisible = imageAdjustments + imageGamma?.isVisible = imageAdjustments // React to changes chkUseWebView?.setOnPreferenceChangeListener { _, newValue -> @@ -52,6 +67,23 @@ class SettingsFragment : PreferenceFragmentCompat() { txtDimTime?.isVisible = value true } + chkImageAdjustments?.setOnPreferenceChangeListener { preference, newValue -> + val value = newValue as Boolean + imageBrightness?.isVisible = value + imageContrast?.isVisible = value + imageRedChannel?.isVisible = value + imageGreenChannel?.isVisible = value + imageBlueChannel?.isVisible = value + imageGamma?.isVisible = value + + // Save the preference value immediately so it takes effect + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putBoolean("imageAdjustments", value) + .apply() + + true + } val chkSettingsLock = findPreference("settingsLock") chkSettingsLock?.setOnPreferenceChangeListener { _, newValue -> val enabling = newValue as Boolean @@ -138,4 +170,16 @@ class SettingsFragment : PreferenceFragmentCompat() { } } } + + @Suppress("DEPRECATION") + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference is SeekBarPreference) { + val dialogFragment = SeekBarPreference.SeekBarPreferenceDialogFragment.newInstance(preference.key) + // setTargetFragment is deprecated but still required by PreferenceDialogFragmentCompat + dialogFragment.setTargetFragment(this, 0) + dialogFragment.show(parentFragmentManager, "SeekBarPreferenceDialog") + } else { + super.onDisplayPreferenceDialog(preference) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt b/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt index 9770612..556980f 100644 --- a/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt +++ b/app/src/main/java/com/immichframe/immichframe/WidgetProvider.kt @@ -90,6 +90,7 @@ class WidgetProvider : AppWidgetProvider() { //randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, maxSize) randomBitmap = Helpers.reduceBitmapQuality(randomBitmap, 1000) + randomBitmap = Helpers.applyImageAdjustmentsToBitmap(randomBitmap, context) withContext(Dispatchers.Main) { views.setImageViewBitmap( diff --git a/app/src/main/res/layout/preference_seekbar.xml b/app/src/main/res/layout/preference_seekbar.xml new file mode 100644 index 0000000..1d1291c --- /dev/null +++ b/app/src/main/res/layout/preference_seekbar.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + +