Skip to content

Add support for custom app fonts, resolves #2747#2801

Open
itsmeadarsh2008 wants to merge 6 commits into
recloudstream:masterfrom
itsmeadarsh2008:feature/custom-app-fonts
Open

Add support for custom app fonts, resolves #2747#2801
itsmeadarsh2008 wants to merge 6 commits into
recloudstream:masterfrom
itsmeadarsh2008:feature/custom-app-fonts

Conversation

@itsmeadarsh2008
Copy link
Copy Markdown

@itsmeadarsh2008 itsmeadarsh2008 commented May 18, 2026

Summary (ok to go)

  • add runtime support for downloading and applying custom app fonts from the coolLabs font API
  • add a UI setting with both suggestions and a free-text font family field
  • keep subtitle rendering untouched while applying the selected font across the rest of the app UI

Notes

  • fonts are cached locally after download
  • the branch history is split into runtime support and settings UI commits

Note: Codex is used to configure woff2-android and its unstability issues and AppFontManager.kt

Copilot AI review requested due to automatic review settings May 18, 2026 11:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds runtime support for downloading and applying a user-selected custom font (via the coolLabs Google-Fonts mirror) across the app UI, plus a new settings entry with a suggestions dropdown and a free-text font-family input. Subtitle rendering is intentionally skipped.

Changes:

  • New AppFontManager that fetches WOFF2 fonts, caches them on disk, and recursively applies the resulting Typeface to every TextView via activity/fragment lifecycle callbacks and a RecyclerView child listener.
  • New bottom-sheet font picker dialog wired into SettingsUI, with new strings, arrays, ids, layout, and preference entry.
  • App registers the font manager's lifecycle callbacks in CloudStreamApp and applies the font in BaseFragment / BasePreferenceFragmentCompat; adds the woff2-android dependency.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
gradle/libs.versions.toml Declares the new woff2Android library/version.
app/build.gradle.kts Adds the woff2-android dependency.
app/src/main/java/com/lagradost/cloudstream3/utils/AppFontManager.kt Core manager: downloads, caches, traverses view trees and applies typefaces.
app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt Wires the new preference to a bottom-sheet picker that calls setSelectedFont and recreates the activity.
app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt Applies fonts to fragment/preference fragment views on view creation.
app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt Registers the manager's activity lifecycle callbacks at app start.
app/src/main/res/layout/bottom_app_font_dialog.xml Bottom-sheet layout: title, suggestions dropdown, family input, apply/cancel buttons.
app/src/main/res/xml/settings_ui.xml Adds the App font preference entry.
app/src/main/res/values/strings.xml New user-facing strings for the font picker.
app/src/main/res/values/donottranslate-strings.xml New preference keys for the selected and recent fonts.
app/src/main/res/values/array.xml Suggested font names array.
app/src/main/res/values/ids.xml New view tag IDs used by the font traversal.
Comments suppressed due to low confidence (5)

app/src/main/java/com/lagradost/cloudstream3/utils/AppFontManager.kt:214

  • When the user changes from a previously-cached font to a different family, loadTypeface immediately returns the stale cached typeface. The first line of loadTypeface calls getCachedTypeface(context), which reads the currently saved selection from SharedPreferences (still the old font, because setSelectedFont has not yet written the new value). If cachedFont matches that old key, it is returned even though fontName argument is different. The user's font change will appear to succeed (Result.success) but the wrong typeface is stored and applied. The cache lookup in loadTypeface should be keyed off the fontName argument, not off the persisted preference.
    private suspend fun loadTypeface(context: Context, fontName: String?): Typeface? {
        fontName ?: return null
        getCachedTypeface(context)?.let { return it }

app/src/main/java/com/lagradost/cloudstream3/utils/AppFontManager.kt:210

  • applyToViewTree runs on the main thread (it is invoked from BasePreferenceFragmentCompat.onViewCreated, BaseFragmentHelper.onViewReady, FragmentManager.FragmentLifecycleCallbacks.onFragmentViewCreated, and the RecyclerView.OnChildAttachStateChangeListener). On the cold path (before warmUp populates cachedFont), getCachedTypeface performs File.exists() and Woff2Typeface.get().createFromFile(file) on the UI thread for every traversal entry point. WOFF2 decoding plus disk I/O on the main thread can cause noticeable jank during navigation, especially since the recycler child listener will repeatedly call applyToViewTree for every newly attached child view. Consider loading the typeface off the main thread and only setting it on views once ready.
    private fun getCachedTypeface(context: Context): Typeface? {
        val fontName = getSelectedFont(context) ?: return null
        val key = cacheKey(fontName, currentLocale(context.resources.configuration))
        synchronized(lock) {
            cachedFont?.takeIf { it.first == key }?.let { return it.second }
        }
        val file = getFontFile(context, fontName, preferredSubset(currentLocale(context.resources.configuration)))
        if (!file.exists()) {
            return null
        }
        return runCatching {
            val typeface = createTypeface(file)
            synchronized(lock) {
                cachedFont = key to typeface
            }
            typeface
        }.getOrNull()
    }

app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt:94

  • The applyBtt / cancelBtt handlers reference viewLifecycleOwner.lifecycleScope from inside a BottomSheetDialog callback. If the bottom sheet outlives the fragment view (e.g., user rotates or backgrounds while the dialog is up), viewLifecycleOwner throws IllegalStateException because the fragment's view has been destroyed. Furthermore, on success this code calls this@SettingsUI.activity?.recreate(), which tears down the fragment immediately; any continuation work on viewLifecycleOwner.lifecycleScope after the suspending setSelectedFont returns may be cancelled or crash. Consider using lifecycleScope of the fragment itself (or the dialog's lifecycle), and capture the activity/context references before suspending.
            viewLifecycleOwner.lifecycleScope.launch {
                val result = AppFontManager.setSelectedFont(activity, selectedFont)
                binding.applyBtt.isEnabled = true
                binding.cancelBtt.isEnabled = true
                result.onSuccess {
                    updateAppFontSummary()
                    dialog.dismiss()
                    this@SettingsUI.activity?.recreate()
                }.onFailure {
                    showToast(activity, it.message ?: getString(R.string.app_font_invalid))
                }
            }

app/src/main/java/com/lagradost/cloudstream3/utils/AppFontManager.kt:48

  • recyclerListener is a single shared object instance registered on every RecyclerView encountered during traversal. Although a tag prevents re-registering on the same RecyclerView, this listener is never removed when the RecyclerView is detached/destroyed. More importantly, since it is a singleton it never references any view directly, but every newly-attached child view triggers a full applyToViewTree walk from that child — including re-entering the same recycler logic if nested RecyclerViews exist. For long-running screens with many list bindings this adds overhead on every item attach. Consider scoping the listener via the recycler's lifecycle or relying on ItemDecoration/adapter notifications instead.
    private val recyclerListener = object : RecyclerView.OnChildAttachStateChangeListener {
        override fun onChildViewAttachedToWindow(view: View) {
            applyToViewTree(view)
        }

        override fun onChildViewDetachedFromWindow(view: View) = Unit
    }

app/src/main/java/com/lagradost/cloudstream3/utils/AppFontManager.kt:191

  • getSelectedFont is called twice (here and inside the immediately-following loadTypeface(context, getSelectedFont(context))), each call doing a SharedPreferences read. Cache the value in a local variable and pass it in to avoid the redundant lookup and the small race window where the preference could change between the two reads.
    private suspend fun warmUp(context: Context) {
        if (getSelectedFont(context) == null) {
            return
        }
        loadTypeface(context, getSelectedFont(context))
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt Outdated
private const val cacheFolder = "app_fonts"
private val faceRegex =
Regex(
"""/\*\s*([^*]+?)\s*\*/\s*@font-face\s*\{.*?src:\s*url\(([^)]+)\)""",
give priority to text field

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@itsmeadarsh2008 itsmeadarsh2008 changed the title Add support for custom app fonts Add support for custom app fonts (WIP 🚧) May 18, 2026
@itsmeadarsh2008 itsmeadarsh2008 changed the title Add support for custom app fonts (WIP 🚧) Add support for custom app fonts, resolves #2747 May 18, 2026
@itsmeadarsh2008
Copy link
Copy Markdown
Author

There are some UI bugs, fixing them rn. Setting priority order for fonts. Requires app restart to change the font.

@itsmeadarsh2008
Copy link
Copy Markdown
Author

Update: doesn't need a restart for suggestive fonts, while if you bring your own font name from Google Fonts, then you will need a restart for it. Also, I have made some UI changes because the name was overlapping the border.

Copy link
Copy Markdown
Contributor

@fire-light43 fire-light43 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like your idea, and your code likely works. However, constantly observing the view tree to replace all typefaces is unacceptable. It can (and will) cause performance issues, edge cases and all sorts of weird bugs.

If you must do runtime fonts then look into using a custom layout inflater. It would apply the font before it's rendered, which would bypass many of the issues caused by runtime layout changes. However, any such change would need to be maintainable and with a minimal performance overhead.

I would much prefer if you just use custom styles with a number of preset fonts. You can still use the font manager to let the user download subtitle fonts.

@fire-light43
Copy link
Copy Markdown
Contributor

cat-work-in-progress

@itsmeadarsh2008
Copy link
Copy Markdown
Author

itsmeadarsh2008 commented May 19, 2026

I like your idea, and your code likely works. However, constantly observing the view tree to replace all typefaces is unacceptable. It can (and will) cause performance issues, edge cases and all sorts of weird bugs.

If you must do runtime fonts, then look into using a custom layout inflater. It would apply the font before it's rendered, which would bypass many of the issues caused by runtime layout changes. However, any such change would need to be maintainable and with a minimal performance overhead.

I would much prefer it if you just use custom styles with a number of preset fonts. You can still use the font manager to let the user download subtitle fonts.

Bro, I tried a few solutions locally, all of them broke. I tried removing "no restart app font change", then that also broke. When I restarted, it didn't change the font. 2nd solution worked for some time, but only while using the app, didn't save to disk. So, restarting the app resets to the default font. 3rd solution, didn't even work lmao.

This is the only solution that I have found to work.

While testing, I didn't face any issues with performance. What are the actual constraints for the minimum supported systems? I think it will also work seamlessly on a 2GB RAM Android TV, too.
image

@fire-light43
Copy link
Copy Markdown
Contributor

2nd solution worked for some time, but only while using the app, didn't save to disk.

That just sounds like a matter of fixing the saving mechanism

3rd solution, didn't even work lmao.

Styles can be confusing, but I am certain they can work. If you need help implementing any specific solution just ask here or in the discord.

While testing, I didn't face any issues with performance. What are the actual constraints for the minimum supported systems? I think it will also work seamlessly on a 2GB RAM Android TV, too.

We are aiming to support old hardware running non-standard Android 6, but text rendering is an issue even on recent devices. A big part of rendering the home page consists of just rendering the text, which means any extra overhead will noticeably impact scrolling smoothness of the home page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants