Add support for custom app fonts, resolves #2747#2801
Conversation
There was a problem hiding this comment.
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
AppFontManagerthat fetches WOFF2 fonts, caches them on disk, and recursively applies the resultingTypefaceto 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
CloudStreamAppand applies the font inBaseFragment/BasePreferenceFragmentCompat; adds thewoff2-androiddependency.
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,
loadTypefaceimmediately returns the stale cached typeface. The first line ofloadTypefacecallsgetCachedTypeface(context), which reads the currently saved selection from SharedPreferences (still the old font, becausesetSelectedFonthas not yet written the new value). IfcachedFontmatches that old key, it is returned even thoughfontNameargument is different. The user's font change will appear to succeed (Result.success) but the wrong typeface is stored and applied. The cache lookup inloadTypefaceshould be keyed off thefontNameargument, 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
applyToViewTreeruns on the main thread (it is invoked fromBasePreferenceFragmentCompat.onViewCreated,BaseFragmentHelper.onViewReady,FragmentManager.FragmentLifecycleCallbacks.onFragmentViewCreated, and theRecyclerView.OnChildAttachStateChangeListener). On the cold path (beforewarmUppopulatescachedFont),getCachedTypefaceperformsFile.exists()andWoff2Typeface.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 callapplyToViewTreefor 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/cancelBtthandlers referenceviewLifecycleOwner.lifecycleScopefrom inside aBottomSheetDialogcallback. If the bottom sheet outlives the fragment view (e.g., user rotates or backgrounds while the dialog is up),viewLifecycleOwnerthrowsIllegalStateExceptionbecause the fragment's view has been destroyed. Furthermore, on success this code callsthis@SettingsUI.activity?.recreate(), which tears down the fragment immediately; any continuation work onviewLifecycleOwner.lifecycleScopeafter the suspendingsetSelectedFontreturns may be cancelled or crash. Consider usinglifecycleScopeof 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
recyclerListeneris a single sharedobjectinstance registered on everyRecyclerViewencountered 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 fullapplyToViewTreewalk 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 onItemDecoration/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
getSelectedFontis called twice (here and inside the immediately-followingloadTypeface(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.
| 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>
|
There are some UI bugs, fixing them rn. Setting priority order for fonts. Requires app restart to change the font. |
|
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. |
fire-light43
left a comment
There was a problem hiding this comment.
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.
That just sounds like a matter of fixing the saving mechanism
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.
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. |


Summary (ok to go)
Notes
Note: Codex is used to configure woff2-android and its unstability issues and AppFontManager.kt