From ca855cbca0730e987abfc60061767bc7e06803c1 Mon Sep 17 00:00:00 2001
From: Isira Seneviratne <isirasen96@gmail.com>
Date: Wed, 20 Nov 2024 08:44:18 +0530
Subject: [PATCH] Migrate to Coil 3

---
 app/build.gradle                              |   7 +-
 app/src/main/java/org/schabi/newpipe/App.kt   | 402 +++++++++---------
 .../java/org/schabi/newpipe/MainActivity.java |   4 +-
 .../fragments/detail/VideoDetailFragment.java |   2 +-
 .../list/channel/ChannelFragment.java         |   2 +-
 .../list/playlist/PlaylistFragment.java       |   2 +-
 .../info_list/dialog/InfoItemDialog.java      |   2 +-
 .../newpipe/local/feed/FeedViewModel.kt       |   2 +-
 .../org/schabi/newpipe/player/Player.java     |  16 +-
 .../newpipe/player/helper/PlayerHolder.java   |   4 +-
 .../SeekbarPreviewThumbnailHolder.java        |   2 +-
 .../settings/ContentSettingsFragment.java     |   4 +-
 .../newpipe/settings/NewPipeSettings.java     |   2 +-
 .../newpipe/settings/SettingMigrations.java   |   6 +-
 .../items/playlist/PlaylistThumbnail.kt       |   2 +-
 .../items/stream/StreamThumbnail.kt           |   2 +-
 .../ui/components/video/comment/Comment.kt    |   2 +-
 .../video/comment/CommentRepliesHeader.kt     |   2 +-
 .../org/schabi/newpipe/util/DeviceUtils.java  |   4 +-
 .../schabi/newpipe/util/ReleaseVersionUtil.kt |   2 +-
 .../external_communication/ShareUtils.java    |  11 +-
 .../schabi/newpipe/util/image/CoilHelper.kt   | 149 ++++---
 22 files changed, 334 insertions(+), 297 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index 24e3d32cf..98270fa70 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -124,6 +124,8 @@ ext {
 
     leakCanaryVersion = '2.12'
     stethoVersion = '1.6.0'
+
+    coilVersion = '3.0.3'
 }
 
 configurations {
@@ -207,7 +209,7 @@ dependencies {
     // This works thanks to JitPack: https://jitpack.io/
     implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
     // WORKAROUND: v0.24.2 can't be resolved by jitpack -> use git commit hash instead
-    implementation 'com.github.TeamNewPipe:NewPipeExtractor:176da72cb4c3ec4679211339b0e59f6b01bf2f52'
+    implementation 'com.github.TeamNewPipe:NewPipeExtractor:d3d5f2b3f03a5f2b479b9f6fdf1c2555cbb9de0e'
     implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
 
 /** Checkstyle **/
@@ -272,7 +274,8 @@ dependencies {
     implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
 
     // Image loading
-    implementation 'io.coil-kt:coil-compose:2.7.0'
+    implementation "io.coil-kt.coil3:coil-compose:${coilVersion}"
+    implementation "io.coil-kt.coil3:coil-network-okhttp:${coilVersion}"
 
     // Markdown library for Android
     implementation "io.noties.markwon:core:${markwonVersion}"
diff --git a/app/src/main/java/org/schabi/newpipe/App.kt b/app/src/main/java/org/schabi/newpipe/App.kt
index d271dba0d..85860dc6a 100644
--- a/app/src/main/java/org/schabi/newpipe/App.kt
+++ b/app/src/main/java/org/schabi/newpipe/App.kt
@@ -1,49 +1,43 @@
-package org.schabi.newpipe;
+package org.schabi.newpipe
 
-import android.app.ActivityManager;
-import android.app.Application;
-import android.content.Context;
-import android.content.SharedPreferences;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.core.app.NotificationChannelCompat;
-import androidx.core.app.NotificationManagerCompat;
-import androidx.core.content.ContextCompat;
-import androidx.preference.PreferenceManager;
-
-import com.jakewharton.processphoenix.ProcessPhoenix;
-
-import org.acra.ACRA;
-import org.acra.config.CoreConfigurationBuilder;
-import org.schabi.newpipe.error.ReCaptchaActivity;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.downloader.Downloader;
-import org.schabi.newpipe.ktx.ExceptionUtils;
-import org.schabi.newpipe.settings.NewPipeSettings;
-import org.schabi.newpipe.util.BridgeStateSaverInitializer;
-import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.ServiceHelper;
-import org.schabi.newpipe.util.StateSaver;
-import org.schabi.newpipe.util.image.ImageStrategy;
-import org.schabi.newpipe.util.image.PreferredImageQuality;
-
-import java.io.IOException;
-import java.io.InterruptedIOException;
-import java.net.SocketException;
-import java.util.List;
-import java.util.Objects;
-
-import coil.ImageLoader;
-import coil.ImageLoaderFactory;
-import coil.util.DebugLogger;
-import dagger.hilt.android.HiltAndroidApp;
-import io.reactivex.rxjava3.exceptions.CompositeException;
-import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
-import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
-import io.reactivex.rxjava3.exceptions.UndeliverableException;
-import io.reactivex.rxjava3.functions.Consumer;
-import io.reactivex.rxjava3.plugins.RxJavaPlugins;
+import android.app.ActivityManager
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
+import androidx.preference.PreferenceManager
+import coil3.ImageLoader
+import coil3.SingletonImageLoader
+import coil3.request.allowRgb565
+import coil3.request.crossfade
+import coil3.util.DebugLogger
+import com.jakewharton.processphoenix.ProcessPhoenix
+import dagger.hilt.android.HiltAndroidApp
+import io.reactivex.rxjava3.exceptions.CompositeException
+import io.reactivex.rxjava3.exceptions.MissingBackpressureException
+import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
+import io.reactivex.rxjava3.exceptions.UndeliverableException
+import io.reactivex.rxjava3.functions.Consumer
+import io.reactivex.rxjava3.plugins.RxJavaPlugins
+import org.acra.ACRA.init
+import org.acra.ACRA.isACRASenderServiceProcess
+import org.acra.config.CoreConfigurationBuilder
+import org.schabi.newpipe.error.ReCaptchaActivity
+import org.schabi.newpipe.extractor.NewPipe
+import org.schabi.newpipe.extractor.downloader.Downloader
+import org.schabi.newpipe.ktx.hasAssignableCause
+import org.schabi.newpipe.settings.NewPipeSettings
+import org.schabi.newpipe.util.BridgeStateSaverInitializer
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.ServiceHelper
+import org.schabi.newpipe.util.StateSaver
+import org.schabi.newpipe.util.image.ImageStrategy
+import org.schabi.newpipe.util.image.PreferredImageQuality
+import java.io.IOException
+import java.io.InterruptedIOException
+import java.net.SocketException
 
 /*
  * Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
@@ -62,218 +56,218 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins;
  * You should have received a copy of the GNU General Public License
  * along with NewPipe.  If not, see <http://www.gnu.org/licenses/>.
  */
-
 @HiltAndroidApp
-public class App extends Application implements ImageLoaderFactory {
-    public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
-    private static final String TAG = App.class.toString();
+open class App :
+    Application(),
+    SingletonImageLoader.Factory {
+    var isFirstRun = false
+        private set
 
-    private boolean isFirstRun = false;
-    private static App app;
-
-    @NonNull
-    public static App getApp() {
-        return app;
+    override fun attachBaseContext(base: Context?) {
+        super.attachBaseContext(base)
+        initACRA()
     }
 
-    @Override
-    protected void attachBaseContext(final Context base) {
-        super.attachBaseContext(base);
-        initACRA();
-    }
+    override fun onCreate() {
+        super.onCreate()
 
-    @Override
-    public void onCreate() {
-        super.onCreate();
-
-        app = this;
+        instance = this
 
         if (ProcessPhoenix.isPhoenixProcess(this)) {
-            Log.i(TAG, "This is a phoenix process! "
-                    + "Aborting initialization of App[onCreate]");
-            return;
+            Log.i(TAG, "This is a phoenix process! Aborting initialization of App[onCreate]")
+            return
         }
 
         // check if the last used preference version is set
         // to determine whether this is the first app run
-        final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
-                .getInt(getString(R.string.last_used_preferences_version), -1);
-        isFirstRun = lastUsedPrefVersion == -1;
+        val lastUsedPrefVersion =
+            PreferenceManager
+                .getDefaultSharedPreferences(this)
+                .getInt(getString(R.string.last_used_preferences_version), -1)
+        isFirstRun = lastUsedPrefVersion == -1
 
         // Initialize settings first because other initializations can use its values
-        NewPipeSettings.initSettings(this);
+        NewPipeSettings.initSettings(this)
 
-        NewPipe.init(getDownloader(),
+        NewPipe.init(
+            getDownloader(),
             Localization.getPreferredLocalization(this),
-            Localization.getPreferredContentCountry(this));
-        Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
+            Localization.getPreferredContentCountry(this),
+        )
+        Localization.initPrettyTime(Localization.resolvePrettyTime(this))
 
-        BridgeStateSaverInitializer.init(this);
-        StateSaver.init(this);
-        initNotificationChannels();
+        BridgeStateSaverInitializer.init(this)
+        StateSaver.init(this)
+        initNotificationChannels()
 
-        ServiceHelper.initServices(this);
+        ServiceHelper.initServices(this)
 
         // Initialize image loader
-        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
-        ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
-                prefs.getString(getString(R.string.image_quality_key),
-                        getString(R.string.image_quality_default))));
+        val prefs = PreferenceManager.getDefaultSharedPreferences(this)
+        ImageStrategy.setPreferredImageQuality(
+            PreferredImageQuality.fromPreferenceKey(
+                this,
+                prefs.getString(
+                    getString(R.string.image_quality_key),
+                    getString(R.string.image_quality_default),
+                ),
+            ),
+        )
 
-        configureRxJavaErrorHandler();
+        configureRxJavaErrorHandler()
     }
 
-    @NonNull
-    @Override
-    public ImageLoader newImageLoader() {
-        return new ImageLoader.Builder(this)
-                .allowRgb565(ContextCompat.getSystemService(this, ActivityManager.class)
-                        .isLowRamDevice())
-                .logger(BuildConfig.DEBUG ? new DebugLogger() : null)
-                .crossfade(true)
-                .build();
+    override fun newImageLoader(context: Context): ImageLoader =
+        ImageLoader
+            .Builder(this)
+            .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
+            .allowRgb565(getSystemService<ActivityManager>()!!.isLowRamDevice)
+            .crossfade(true)
+            .build()
+
+    protected open fun getDownloader(): Downloader {
+        val downloader = DownloaderImpl.init(null)
+        setCookiesToDownloader(downloader)
+        return downloader
     }
 
-    protected Downloader getDownloader() {
-        final DownloaderImpl downloader = DownloaderImpl.init(null);
-        setCookiesToDownloader(downloader);
-        return downloader;
+    protected fun setCookiesToDownloader(downloader: DownloaderImpl) {
+        val prefs = PreferenceManager.getDefaultSharedPreferences(this)
+        val key = getString(R.string.recaptcha_cookies_key)
+        downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null))
+        downloader.updateYoutubeRestrictedModeCookies(this)
     }
 
-    protected void setCookiesToDownloader(final DownloaderImpl downloader) {
-        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
-                getApplicationContext());
-        final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
-        downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null));
-        downloader.updateYoutubeRestrictedModeCookies(getApplicationContext());
-    }
-
-    private void configureRxJavaErrorHandler() {
+    private fun configureRxJavaErrorHandler() {
         // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
-        RxJavaPlugins.setErrorHandler(new Consumer<Throwable>() {
-            @Override
-            public void accept(@NonNull final Throwable throwable) {
-                Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : "
-                        + "throwable = [" + throwable.getClass().getName() + "]");
+        RxJavaPlugins.setErrorHandler(
+            object : Consumer<Throwable> {
+                override fun accept(throwable: Throwable) {
+                    Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [${throwable.javaClass.getName()}]")
 
-                final Throwable actualThrowable;
-                if (throwable instanceof UndeliverableException) {
                     // As UndeliverableException is a wrapper,
                     // get the cause of it to get the "real" exception
-                    actualThrowable = Objects.requireNonNull(throwable.getCause());
-                } else {
-                    actualThrowable = throwable;
-                }
+                    val actualThrowable = (throwable as? UndeliverableException)?.cause ?: throwable
 
-                final List<Throwable> errors;
-                if (actualThrowable instanceof CompositeException) {
-                    errors = ((CompositeException) actualThrowable).getExceptions();
-                } else {
-                    errors = List.of(actualThrowable);
-                }
+                    val errors = (actualThrowable as? CompositeException)?.exceptions ?: listOf(actualThrowable)
 
-                for (final Throwable error : errors) {
-                    if (isThrowableIgnored(error)) {
-                        return;
+                    for (error in errors) {
+                        if (isThrowableIgnored(error)) {
+                            return
+                        }
+                        if (isThrowableCritical(error)) {
+                            reportException(error)
+                            return
+                        }
                     }
-                    if (isThrowableCritical(error)) {
-                        reportException(error);
-                        return;
+
+                    // Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
+                    // When exception is not reported, log it
+                    if (isDisposedRxExceptionsReported()) {
+                        reportException(actualThrowable)
+                    } else {
+                        Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable)
                     }
                 }
 
-                // Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
-                // When exception is not reported, log it
-                if (isDisposedRxExceptionsReported()) {
-                    reportException(actualThrowable);
-                } else {
-                    Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable);
+                fun isThrowableIgnored(throwable: Throwable): Boolean {
+                    // Don't crash the application over a simple network problem
+                    return throwable // network api cancellation
+                        .hasAssignableCause(
+                            IOException::class.java,
+                            SocketException::class.java, // blocking code disposed
+                            InterruptedException::class.java,
+                            InterruptedIOException::class.java,
+                        )
                 }
-            }
 
-            private boolean isThrowableIgnored(@NonNull final Throwable throwable) {
-                // Don't crash the application over a simple network problem
-                return ExceptionUtils.hasAssignableCause(throwable,
-                        // network api cancellation
-                        IOException.class, SocketException.class,
-                        // blocking code disposed
-                        InterruptedException.class, InterruptedIOException.class);
-            }
+                fun isThrowableCritical(throwable: Throwable): Boolean {
+                    // Though these exceptions cannot be ignored
+                    return throwable
+                        .hasAssignableCause(
+                            NullPointerException::class.java,
+                            IllegalArgumentException::class.java, // bug in app
+                            OnErrorNotImplementedException::class.java,
+                            MissingBackpressureException::class.java,
+                            IllegalStateException::class.java,
+                        ) // bug in operator
+                }
 
-            private boolean isThrowableCritical(@NonNull final Throwable throwable) {
-                // Though these exceptions cannot be ignored
-                return ExceptionUtils.hasAssignableCause(throwable,
-                        NullPointerException.class, IllegalArgumentException.class, // bug in app
-                        OnErrorNotImplementedException.class, MissingBackpressureException.class,
-                        IllegalStateException.class); // bug in operator
-            }
-
-            private void reportException(@NonNull final Throwable throwable) {
-                // Throw uncaught exception that will trigger the report system
-                Thread.currentThread().getUncaughtExceptionHandler()
-                        .uncaughtException(Thread.currentThread(), throwable);
-            }
-        });
+                fun reportException(throwable: Throwable) {
+                    // Throw uncaught exception that will trigger the report system
+                    Thread.currentThread().uncaughtExceptionHandler
+                        .uncaughtException(Thread.currentThread(), throwable)
+                }
+            },
+        )
     }
 
     /**
-     * Called in {@link #attachBaseContext(Context)} after calling the {@code super} method.
+     * Called in [.attachBaseContext] after calling the `super` method.
      * Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
      */
-    protected void initACRA() {
-        if (ACRA.isACRASenderServiceProcess()) {
-            return;
+    protected fun initACRA() {
+        if (isACRASenderServiceProcess()) {
+            return
         }
 
-        final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
-                .withBuildConfigClass(BuildConfig.class);
-        ACRA.init(this, acraConfig);
+        val acraConfig =
+            CoreConfigurationBuilder()
+                .withBuildConfigClass(BuildConfig::class.java)
+        init(this, acraConfig)
     }
 
-    private void initNotificationChannels() {
+    private fun initNotificationChannels() {
         // Keep the importance below DEFAULT to avoid making noise on every notification update for
         // the main and update channels
-        final List<NotificationChannelCompat> notificationChannelCompats = List.of(
-                new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
-                        NotificationManagerCompat.IMPORTANCE_LOW)
-                        .setName(getString(R.string.notification_channel_name))
-                        .setDescription(getString(R.string.notification_channel_description))
-                        .build(),
-                new NotificationChannelCompat
-                        .Builder(getString(R.string.app_update_notification_channel_id),
-                        NotificationManagerCompat.IMPORTANCE_LOW)
-                        .setName(getString(R.string.app_update_notification_channel_name))
-                        .setDescription(
-                                getString(R.string.app_update_notification_channel_description))
-                        .build(),
-                new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
-                        NotificationManagerCompat.IMPORTANCE_HIGH)
-                        .setName(getString(R.string.hash_channel_name))
-                        .setDescription(getString(R.string.hash_channel_description))
-                        .build(),
-                new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
-                        NotificationManagerCompat.IMPORTANCE_LOW)
-                        .setName(getString(R.string.error_report_channel_name))
-                        .setDescription(getString(R.string.error_report_channel_description))
-                        .build(),
-                new NotificationChannelCompat
-                        .Builder(getString(R.string.streams_notification_channel_id),
-                        NotificationManagerCompat.IMPORTANCE_DEFAULT)
-                        .setName(getString(R.string.streams_notification_channel_name))
-                        .setDescription(
-                                getString(R.string.streams_notification_channel_description))
-                        .build()
-        );
+        val mainChannel = NotificationChannelCompat.Builder(
+                getString(R.string.notification_channel_id),
+                NotificationManagerCompat.IMPORTANCE_LOW,
+            )
+            .setName(getString(R.string.notification_channel_name))
+            .setDescription(getString(R.string.notification_channel_description))
+            .build()
+        val appUpdateChannel = NotificationChannelCompat.Builder(
+                getString(R.string.app_update_notification_channel_id),
+                NotificationManagerCompat.IMPORTANCE_LOW,
+            )
+            .setName(getString(R.string.app_update_notification_channel_name))
+            .setDescription(getString(R.string.app_update_notification_channel_description))
+            .build()
+        val hashChannel = NotificationChannelCompat.Builder(
+                getString(R.string.hash_channel_id),
+                NotificationManagerCompat.IMPORTANCE_HIGH,
+            )
+            .setName(getString(R.string.hash_channel_name))
+            .setDescription(getString(R.string.hash_channel_description))
+            .build()
+        val errorReportChannel = NotificationChannelCompat.Builder(
+                getString(R.string.error_report_channel_id),
+                NotificationManagerCompat.IMPORTANCE_LOW,
+            )
+            .setName(getString(R.string.error_report_channel_name))
+            .setDescription(getString(R.string.error_report_channel_description))
+            .build()
+        val newStreamChannel = NotificationChannelCompat.Builder(
+                getString(R.string.streams_notification_channel_id),
+                NotificationManagerCompat.IMPORTANCE_DEFAULT,
+            )
+            .setName(getString(R.string.streams_notification_channel_name))
+            .setDescription(getString(R.string.streams_notification_channel_description))
+            .build()
 
-        final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
-        notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
+        val channels = listOf(mainChannel, appUpdateChannel, hashChannel, errorReportChannel, newStreamChannel)
+
+        NotificationManagerCompat.from(this).createNotificationChannelsCompat(channels)
     }
 
-    protected boolean isDisposedRxExceptionsReported() {
-        return false;
-    }
+    protected open fun isDisposedRxExceptionsReported(): Boolean = false
 
-    public boolean isFirstRun() {
-        return isFirstRun;
+    companion object {
+        const val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID
+        private val TAG = App::class.java.toString()
+
+        @JvmStatic
+        lateinit var instance: App
     }
 }
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 41abd10c1..39431537d 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -166,7 +166,7 @@ public class MainActivity extends AppCompatActivity {
             NotificationWorker.initialize(this);
         }
         if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
-                && !App.getApp().isFirstRun()
+                && !App.getInstance().isFirstRun()
                 && ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
             UpdateSettingsFragment.askForConsentToUpdateChecks(this);
         }
@@ -176,7 +176,7 @@ public class MainActivity extends AppCompatActivity {
     protected void onPostCreate(final Bundle savedInstanceState) {
         super.onPostCreate(savedInstanceState);
 
-        final App app = App.getApp();
+        final App app = App.getInstance();
         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
 
         if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 4f5bd9e94..63077e92d 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -127,7 +127,7 @@ import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
-import coil.util.CoilUtils;
+import coil3.util.CoilUtils;
 import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
 import io.reactivex.rxjava3.disposables.CompositeDisposable;
 import io.reactivex.rxjava3.disposables.Disposable;
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
index 56d8a9315..55e3ae52a 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
@@ -60,7 +60,7 @@ import java.util.List;
 import java.util.Queue;
 import java.util.concurrent.TimeUnit;
 
-import coil.util.CoilUtils;
+import coil3.util.CoilUtils;
 import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
 import io.reactivex.rxjava3.core.Observable;
 import io.reactivex.rxjava3.disposables.CompositeDisposable;
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
index d4607a9ff..be4f076dd 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
@@ -62,7 +62,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
-import coil.util.CoilUtils;
+import coil3.util.CoilUtils;
 import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
 import io.reactivex.rxjava3.core.Flowable;
 import io.reactivex.rxjava3.core.Single;
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
index 0c69557bf..dcf01e190 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
@@ -346,7 +346,7 @@ public final class InfoItemDialog {
 
         public static void reportErrorDuringInitialization(final Throwable throwable,
                                                            final InfoItem item) {
-            ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
+            ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo(
                     throwable,
                     UserAction.OPEN_INFO_ITEM_DIALOG,
                     "none",
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
index 728570b17..462e8ef21 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
@@ -165,7 +165,7 @@ class FeedViewModel(
         fun getFactory(context: Context, groupId: Long) = viewModelFactory {
             initializer {
                 FeedViewModel(
-                    App.getApp(),
+                    App.instance,
                     groupId,
                     // Read initial value from preferences
                     getShowPlayedItemsFromPreferences(context.applicationContext),
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 74d35cf31..ab5274996 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -46,6 +46,7 @@ import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
 import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
 import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static coil3.Image_androidKt.toBitmap;
 
 import android.content.BroadcastReceiver;
 import android.content.Context;
@@ -53,14 +54,12 @@ import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.SharedPreferences;
 import android.graphics.Bitmap;
-import android.graphics.drawable.Drawable;
 import android.media.AudioManager;
 import android.util.Log;
 import android.view.LayoutInflater;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.core.graphics.drawable.DrawableKt;
 import androidx.core.math.MathUtils;
 import androidx.preference.PreferenceManager;
 
@@ -125,7 +124,7 @@ import java.util.List;
 import java.util.Optional;
 import java.util.stream.IntStream;
 
-import coil.target.Target;
+import coil3.target.Target;
 import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
 import io.reactivex.rxjava3.core.Observable;
 import io.reactivex.rxjava3.disposables.CompositeDisposable;
@@ -193,7 +192,7 @@ public final class Player implements PlaybackListener, Listener {
     @Nullable
     private Bitmap currentThumbnail;
     @Nullable
-    private coil.request.Disposable thumbnailDisposable;
+    private coil3.request.Disposable thumbnailDisposable;
 
     /*//////////////////////////////////////////////////////////////////////////
     // Player
@@ -789,27 +788,26 @@ public final class Player implements PlaybackListener, Listener {
         // scale down the notification thumbnail for performance
         final var thumbnailTarget = new Target() {
             @Override
-            public void onError(@Nullable final Drawable error) {
+            public void onError(@Nullable final coil3.Image error) {
                 Log.e(TAG, "Thumbnail - onError() called");
                 // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
                 onThumbnailLoaded(null);
             }
 
             @Override
-            public void onStart(@Nullable final Drawable placeholder) {
+            public void onStart(@Nullable final coil3.Image placeholder) {
                 if (DEBUG) {
                     Log.d(TAG, "Thumbnail - onStart() called");
                 }
             }
 
             @Override
-            public void onSuccess(@NonNull final Drawable result) {
+            public void onSuccess(@NonNull final coil3.Image result) {
                 if (DEBUG) {
                     Log.d(TAG, "Thumbnail - onSuccess() called with: drawable = [" + result + "]");
                 }
                 // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
-                onThumbnailLoaded(DrawableKt.toBitmapOrNull(result, result.getIntrinsicWidth(),
-                        result.getIntrinsicHeight(), null));
+                onThumbnailLoaded(toBitmap(result));
             }
         };
         thumbnailDisposable = CoilHelper.INSTANCE
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
index b55a6547a..24939c1d8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
@@ -16,8 +16,8 @@ import com.google.android.exoplayer2.PlaybackParameters;
 import org.schabi.newpipe.App;
 import org.schabi.newpipe.MainActivity;
 import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.player.PlayerService;
 import org.schabi.newpipe.player.Player;
+import org.schabi.newpipe.player.PlayerService;
 import org.schabi.newpipe.player.PlayerType;
 import org.schabi.newpipe.player.event.PlayerServiceEventListener;
 import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
@@ -116,7 +116,7 @@ public final class PlayerHolder {
     // helper to handle context in common place as using the same
     // context to bind/unbind a service is crucial
     private Context getCommonContext() {
-        return App.getApp();
+        return App.getInstance();
     }
 
     public void startService(final boolean playAfterConnect,
diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
index d09664aeb..863e2fb8a 100644
--- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
@@ -179,7 +179,7 @@ public class SeekbarPreviewThumbnailHolder {
 
             // Gets the bitmap within the timeout of 15 seconds imposed by default by OkHttpClient
             // Ensure that you are not running on the main thread, otherwise this will hang
-            final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getApp(), url);
+            final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), url);
 
             if (sw != null) {
                 Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took "
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
index c47abb930..bb3828265 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
@@ -15,7 +15,7 @@ import org.schabi.newpipe.extractor.localization.Localization;
 import org.schabi.newpipe.util.image.ImageStrategy;
 import org.schabi.newpipe.util.image.PreferredImageQuality;
 
-import coil.Coil;
+import coil3.SingletonImageLoader;
 
 public class ContentSettingsFragment extends BasePreferenceFragment {
     private String youtubeRestrictedModeEnabledKey;
@@ -41,7 +41,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
                 (preference, newValue) -> {
                     ImageStrategy.setPreferredImageQuality(PreferredImageQuality
                             .fromPreferenceKey(requireContext(), (String) newValue));
-                    final var loader = Coil.imageLoader(preference.getContext());
+                    final var loader =  SingletonImageLoader.get(preference.getContext());
                     loader.getMemoryCache().clear();
                     loader.getDiskCache().clear();
                     Toast.makeText(preference.getContext(),
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index 421440ea7..9fe5240cc 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -156,7 +156,7 @@ public final class NewPipeSettings {
                 prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0
                         && !prefs.getBoolean(disabledTunnelingKey, false);
 
-        if (App.getApp().isFirstRun()
+        if (App.getInstance().isFirstRun()
                 || (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) {
             setMediaTunneling(context);
         }
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
index d731f2f5e..a77e1c514 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
@@ -1,5 +1,7 @@
 package org.schabi.newpipe.settings;
 
+import static org.schabi.newpipe.MainActivity.DEBUG;
+
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.util.Log;
@@ -18,8 +20,6 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
 
-import static org.schabi.newpipe.MainActivity.DEBUG;
-
 /**
  * In order to add a migration, follow these steps, given P is the previous version:<br>
  * - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in
@@ -171,7 +171,7 @@ public final class SettingMigrations {
         final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0);
 
         // no migration to run, already up to date
-        if (App.getApp().isFirstRun()) {
+        if (App.getInstance().isFirstRun()) {
             sp.edit().putInt(lastPrefVersionKey, VERSION).apply();
             return;
         } else if (lastPrefVersion == VERSION) {
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt
index b1d4d200c..36711105b 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt
@@ -18,7 +18,7 @@ import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import org.schabi.newpipe.R
 import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
 import org.schabi.newpipe.util.Localization
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt
index bcccd3217..f5515a24a 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.viewmodel.compose.viewModel
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import org.schabi.newpipe.R
 import org.schabi.newpipe.extractor.stream.StreamInfoItem
 import org.schabi.newpipe.util.Localization
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt
index 7f29269d1..efa87b581 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt
@@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.PreviewParameter
 import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import org.schabi.newpipe.R
 import org.schabi.newpipe.extractor.Page
 import org.schabi.newpipe.extractor.comments.CommentsInfoItem
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt
index 95542092e..e6627f7f0 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt
@@ -28,7 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import org.schabi.newpipe.R
 import org.schabi.newpipe.extractor.comments.CommentsInfoItem
 import org.schabi.newpipe.extractor.stream.Description
diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
index e9678c2b0..7a357a0c1 100644
--- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
+++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
@@ -130,7 +130,7 @@ public final class DeviceUtils {
         }
 
         isFireTV =
-                App.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV);
+                App.getInstance().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV);
         return isFireTV;
     }
 
@@ -139,7 +139,7 @@ public final class DeviceUtils {
             return isTV;
         }
 
-        final PackageManager pm = App.getApp().getPackageManager();
+        final PackageManager pm = App.getInstance().getPackageManager();
 
         // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check
         boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class)
diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
index 3ea19fa4f..080f5bace 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
@@ -21,7 +21,7 @@ object ReleaseVersionUtil {
         val certificates = mapOf(
             RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256
         )
-        val app = App.getApp()
+        val app = App.instance
         try {
             PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false)
         } catch (e: PackageManager.NameNotFoundException) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java
index 9008a213d..4be5445bc 100644
--- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java
+++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java
@@ -1,6 +1,7 @@
 package org.schabi.newpipe.util.external_communication;
 
 import static org.schabi.newpipe.MainActivity.DEBUG;
+import static coil3.Image_androidKt.toBitmap;
 
 import android.content.ActivityNotFoundException;
 import android.content.ClipData;
@@ -31,9 +32,9 @@ import java.nio.file.Files;
 import java.util.Collections;
 import java.util.List;
 
-import coil.Coil;
-import coil.disk.DiskCache;
-import coil.memory.MemoryCache;
+import coil3.SingletonImageLoader;
+import coil3.disk.DiskCache;
+import coil3.memory.MemoryCache;
 
 public final class ShareUtils {
     private static final String TAG = ShareUtils.class.getSimpleName();
@@ -377,13 +378,13 @@ public final class ShareUtils {
             // Save the image in memory to the application's cache because we need a URI to the
             // image to generate a ClipData which will show the share sheet, and so an image file
             final Context applicationContext = context.getApplicationContext();
-            final var loader = Coil.imageLoader(context);
+            final var loader = SingletonImageLoader.get(context);
             final var value = loader.getMemoryCache()
                     .get(new MemoryCache.Key(thumbnailUrl, Collections.emptyMap()));
 
             final Bitmap cachedBitmap;
             if (value != null) {
-                cachedBitmap = value.getBitmap();
+                cachedBitmap = toBitmap(value.getImage());
             } else {
                 try (var snapshot = loader.getDiskCache().openSnapshot(thumbnailUrl)) {
                     if (snapshot != null) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt
index 2608090dc..5b393658c 100644
--- a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt
@@ -5,14 +5,18 @@ import android.graphics.Bitmap
 import android.util.Log
 import android.widget.ImageView
 import androidx.annotation.DrawableRes
-import androidx.core.graphics.drawable.toBitmapOrNull
-import coil.executeBlocking
-import coil.imageLoader
-import coil.request.Disposable
-import coil.request.ImageRequest
-import coil.size.Size
-import coil.target.Target
-import coil.transform.Transformation
+import coil3.executeBlocking
+import coil3.imageLoader
+import coil3.request.Disposable
+import coil3.request.ImageRequest
+import coil3.request.error
+import coil3.request.placeholder
+import coil3.request.target
+import coil3.request.transformations
+import coil3.size.Size
+import coil3.target.Target
+import coil3.toBitmap
+import coil3.transform.Transformation
 import org.schabi.newpipe.MainActivity
 import org.schabi.newpipe.R
 import org.schabi.newpipe.extractor.Image
@@ -26,84 +30,119 @@ object CoilHelper {
     fun loadBitmapBlocking(
         context: Context,
         url: String?,
-        @DrawableRes placeholderResId: Int = 0
-    ): Bitmap? {
-        val request = getImageRequest(context, url, placeholderResId).build()
-        return context.imageLoader.executeBlocking(request).drawable?.toBitmapOrNull()
-    }
+        @DrawableRes placeholderResId: Int = 0,
+    ): Bitmap? =
+        context.imageLoader
+            .executeBlocking(getImageRequest(context, url, placeholderResId).build())
+            .image
+            ?.toBitmap()
 
-    fun loadAvatar(target: ImageView, images: List<Image>) {
+    fun loadAvatar(
+        target: ImageView,
+        images: List<Image>,
+    ) {
         loadImageDefault(target, images, R.drawable.placeholder_person)
     }
 
-    fun loadAvatar(target: ImageView, url: String?) {
+    fun loadAvatar(
+        target: ImageView,
+        url: String?,
+    ) {
         loadImageDefault(target, url, R.drawable.placeholder_person)
     }
 
-    fun loadThumbnail(target: ImageView, images: List<Image>) {
+    fun loadThumbnail(
+        target: ImageView,
+        images: List<Image>,
+    ) {
         loadImageDefault(target, images, R.drawable.placeholder_thumbnail_video)
     }
 
-    fun loadThumbnail(target: ImageView, url: String?) {
+    fun loadThumbnail(
+        target: ImageView,
+        url: String?,
+    ) {
         loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video)
     }
 
-    fun loadScaledDownThumbnail(context: Context, images: List<Image>, target: Target): Disposable {
+    fun loadScaledDownThumbnail(
+        context: Context,
+        images: List<Image>,
+        target: Target,
+    ): Disposable {
         val url = ImageStrategy.choosePreferredImage(images)
-        val request = getImageRequest(context, url, R.drawable.placeholder_thumbnail_video)
-            .target(target)
-            .transformations(object : Transformation {
-                override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"
+        val request =
+            getImageRequest(context, url, R.drawable.placeholder_thumbnail_video)
+                .target(target)
+                .transformations(
+                    object : Transformation() {
+                        override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"
 
-                override suspend fun transform(input: Bitmap, size: Size): Bitmap {
-                    if (MainActivity.DEBUG) {
-                        Log.d(TAG, "Thumbnail - transform() called")
-                    }
+                        override suspend fun transform(
+                            input: Bitmap,
+                            size: Size,
+                        ): Bitmap {
+                            if (MainActivity.DEBUG) {
+                                Log.d(TAG, "Thumbnail - transform() called")
+                            }
 
-                    val notificationThumbnailWidth = min(
-                        context.resources.getDimension(R.dimen.player_notification_thumbnail_width),
-                        input.width.toFloat()
-                    ).toInt()
+                            val notificationThumbnailWidth =
+                                min(
+                                    context.resources.getDimension(R.dimen.player_notification_thumbnail_width),
+                                    input.width.toFloat(),
+                                ).toInt()
 
-                    var newHeight = input.height / (input.width / notificationThumbnailWidth)
-                    val result = input.scale(notificationThumbnailWidth, newHeight)
+                            var newHeight = input.height / (input.width / notificationThumbnailWidth)
+                            val result = input.scale(notificationThumbnailWidth, newHeight)
 
-                    return if (result == input || !result.isMutable) {
-                        // create a new mutable bitmap to prevent strange crashes on some
-                        // devices (see #4638)
-                        newHeight = input.height / (input.width / (notificationThumbnailWidth - 1))
-                        input.scale(notificationThumbnailWidth, newHeight)
-                    } else {
-                        result
-                    }
-                }
-            })
-            .build()
+                            return if (result == input || !result.isMutable) {
+                                // create a new mutable bitmap to prevent strange crashes on some
+                                // devices (see #4638)
+                                newHeight = input.height / (input.width / (notificationThumbnailWidth - 1))
+                                input.scale(notificationThumbnailWidth, newHeight)
+                            } else {
+                                result
+                            }
+                        }
+                    },
+                ).build()
 
         return context.imageLoader.enqueue(request)
     }
 
-    fun loadDetailsThumbnail(target: ImageView, images: List<Image>) {
+    fun loadDetailsThumbnail(
+        target: ImageView,
+        images: List<Image>,
+    ) {
         val url = ImageStrategy.choosePreferredImage(images)
         loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video, false)
     }
 
-    fun loadBanner(target: ImageView, images: List<Image>) {
+    fun loadBanner(
+        target: ImageView,
+        images: List<Image>,
+    ) {
         loadImageDefault(target, images, R.drawable.placeholder_channel_banner)
     }
 
-    fun loadPlaylistThumbnail(target: ImageView, images: List<Image>) {
+    fun loadPlaylistThumbnail(
+        target: ImageView,
+        images: List<Image>,
+    ) {
         loadImageDefault(target, images, R.drawable.placeholder_thumbnail_playlist)
     }
 
-    fun loadPlaylistThumbnail(target: ImageView, url: String?) {
+    fun loadPlaylistThumbnail(
+        target: ImageView,
+        url: String?,
+    ) {
         loadImageDefault(target, url, R.drawable.placeholder_thumbnail_playlist)
     }
 
     private fun loadImageDefault(
         target: ImageView,
         images: List<Image>,
-        @DrawableRes placeholderResId: Int
+        @DrawableRes placeholderResId: Int,
     ) {
         loadImageDefault(target, ImageStrategy.choosePreferredImage(images), placeholderResId)
     }
@@ -112,11 +151,12 @@ object CoilHelper {
         target: ImageView,
         url: String?,
         @DrawableRes placeholderResId: Int,
-        showPlaceholder: Boolean = true
+        showPlaceholder: Boolean = true,
     ) {
-        val request = getImageRequest(target.context, url, placeholderResId, showPlaceholder)
-            .target(target)
-            .build()
+        val request =
+            getImageRequest(target.context, url, placeholderResId, showPlaceholder)
+                .target(target)
+                .build()
         target.context.imageLoader.enqueue(request)
     }
 
@@ -124,14 +164,15 @@ object CoilHelper {
         context: Context,
         url: String?,
         @DrawableRes placeholderResId: Int,
-        showPlaceholderWhileLoading: Boolean = true
+        showPlaceholderWhileLoading: Boolean = true,
     ): ImageRequest.Builder {
         // if the URL was chosen with `choosePreferredImage` it will be null, but check again
         // `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case
         // for URLs stored in the database)
         val takenUrl = url?.takeIf { it.isNotEmpty() && ImageStrategy.shouldLoadImages() }
 
-        return ImageRequest.Builder(context)
+        return ImageRequest
+            .Builder(context)
             .data(takenUrl)
             .error(placeholderResId)
             .memoryCacheKey(takenUrl)