Many moons ago, I wrote about making a screensaver for Android TV that was inspired by the Netflix screensaver. The setup was fairly hacky, a small web server hosting a curated set of movie and TV artwork, and an Android app that simply pointed the screensaver at a URL.
It worked for a while but new films and shows meant constantly updating the CMS, and eventually it fell out of date and got abandoned. The obvious fix was to pull directly from my Plex library so it always reflected what I actually owned. I just never got around to it. Until recently.
The goal was simple: no CMS, no manual curation, no stale artwork. Just authenticate with Plex, pull whatever I actually own, and display it as a native Android TV screensaver that doesn’t look like a demo loop.
I actually thought this part would be pretty straightforward. I initially started using TMDB and TVDB as a source for the artwork. TMDB backdrops are fine for apps, but unreliable for ambient use, too much text, inconsistent language, and a lot of marketing-led imagery.
I realised that Plex recently released their new experience on mobile which had fantastic high res logo artwork. When Plex scans your media library it auto fetches metadata including these logo artwork. By default these aren’t exposed through the standard API, but if you include the following to the request.
includeImages=1&includeExtras=1
Plex returns XML with <Image> child elements like this:
<Video>
<Image type="clearLogo" url="/library/metadata/12345/clearLogo/67890" />
</Video>
I found these to be pretty good, they’re usually white or light-coloured, which makes them easy to place cleanly over darker backgrounds. In case Plex hasn’t got a clear logo for a media item I set a fallback to Fanart.tv or TMDB if it still fails.
This was the tricky one, as mentioned I started off using TMDB/TVDB. However I was getting pretty poor responses. I looked into figuring out where Plex got it’s backdrop artwork from, but couldn’t seem to find anything in the API like the clearLogo. It’s also not the one that you manually select in the UI either.
After digging around for alternatives, I landed on Fanart.tv, which has a huge amount of high-quality backdrop artwork and crucially, is mostly logo-free.
We then filter these based on the following:
lang=""/**
* Get best background from a list of Fanart images
* Prefers: 1) Highest resolution, 2) Text-free (lang=""), 3) Most likes
*/
private fun selectBestBackground(backgrounds: List<FanartImage>?): FanartImage? {
if (backgrounds.isNullOrEmpty()) return null
// Prefer text-free backgrounds (empty lang)
val textFree = backgrounds.filter { it.isTextFree }
val candidates = if (textFree.isNotEmpty()) textFree else backgrounds.filter { it.lang == "en" }.ifEmpty { backgrounds }
// Sort by resolution (highest first), then by likes
val sorted = candidates.sortedWith(
compareByDescending<FanartImage> { it.widthInt }
.thenByDescending { it.likesInt }
)
val best = sorted.firstOrNull()
if (best != null) {
val langType = if (best.isTextFree) "text-free" else "lang=${best.lang}"
Log.d(TAG, "Selected $langType background: ${best.resolution}, ${best.likesInt} likes")
}
return best
}If nothing can be found it’ll then fall back to Plex, or TMDB/TVDB.
I initially started off wanting full 2160p artwork, however Fanart does not seem to expose 4K artwork through the api. Hopefully in the future they add this, but 1080p still looks fantastic as most 4K tvs will upscale.
For the full Netflix inspired effect, I wanted to avoid just darkening the corners of the image where I’d display the film/show logo. I initially wanted to use a coloured blur radiating from the left corner, however after lots of trial and error. I figured out that Android TV on my NVIDIA Shield Pro doesn’t support blurring.
The next best thing was to add a radial gradient positioned in the corner using the prominent colour from that area of the image. Plex have managed this in their app by saving prominent colours in the image file in the XML for each media item.
However as I was grabbing these media items that could be different each time, this wouldn’t work. So as each image loads the app works out the prominent colours from that section of the image and applies them to the gradient creating a more seamless effect behind the logo artwork.
private fun updateGradientsWithImageColor(targetView: ImageView) {
val drawable = targetView.drawable ?: return
scope.launch(Dispatchers.Default) {
try {
// Get bitmap from drawable
val originalBitmap = if (drawable is BitmapDrawable) {
drawable.bitmap
} else {
drawable.toBitmap()
}
// Convert hardware bitmap to software bitmap if needed
val softwareBitmap = if (originalBitmap.config == android.graphics.Bitmap.Config.HARDWARE) {
originalBitmap.copy(android.graphics.Bitmap.Config.ARGB_8888, false)
} else {
originalBitmap
}
val width = softwareBitmap.width
val height = softwareBitmap.height
// Define corner regions (bottom 40% height, left/right 40% width)
val cornerWidth = (width * 0.4f).toInt()
val cornerHeight = (height * 0.4f).toInt()
val bottomY = height - cornerHeight
// Extract bottom-left corner
val bottomLeftBitmap = android.graphics.Bitmap.createBitmap(
softwareBitmap,
0,
bottomY,
cornerWidth,
cornerHeight
)
// Extract bottom-right corner
val bottomRightBitmap = android.graphics.Bitmap.createBitmap(
softwareBitmap,
width - cornerWidth,
bottomY,
cornerWidth,
cornerHeight
)This is handled using a simple priority system:
This priority ensures the gradients are dark enough to maintain contrast with the white logo text while still having colour personality from the artwork.
We then smoothly transition the gradient as each new artwork loads.
Finally adding some slight movement to the image as it’s on screen to reinforce the separation between the logo and the backdrop I added a slight Ken Burns effect that slowly pans the image across the screen.
private fun applyKenBurnsEffect(targetView: ImageView) {
// Random horizontal direction: pan left or right
val panDistance = 60f
val startTranslateX = if (Math.random() < 0.5) -panDistance else panDistance
val endTranslateX = -startTranslateX
// Zoom in more for a more obvious pan effect
val scale = 1.15f
// Cancel any existing animation to prevent glitches
targetView.animate().cancel()
targetView.clearAnimation()
// Reset position and apply zoom
targetView.scaleX = scale
targetView.scaleY = scale
targetView.translationY = 0f
targetView.translationX = startTranslateX
// Animate horizontal pan - much longer duration to continue throughout entire display
// Make it 2x the rotation interval to ensure smooth continuous motion
val animationDuration = ROTATION_INTERVAL_MS * 2
targetView.animate()
.translationX(endTranslateX)
.setDuration(animationDuration.toLong())
.setInterpolator(android.view.animation.LinearInterpolator())
.withEndAction(null) // Clear any end actions
.start()
}Then when transitioning between each image we load in the new image behind the current and crossfade between the two to avoid having fade to black transition.
companion object {
private const val TAG = "ScreensaverController"
private const val ROTATION_INTERVAL_MS = 10000L // 10 seconds total per slide
private const val CROSSFADE_DURATION_MS = 2000L // 2 seconds for backdrop transitions
private const val LOGO_FADE_IN_DELAY_MS = 500L // Delay after backdrop before logo appears
private const val LOGO_FADE_DURATION_MS = 1000L // Logo fade in/out duration
private const val LOGO_DISPLAY_TIME_MS = 15000L // How long logo stays visible
}The app itself is fully working. The remaining work is around distribution, specifically allowing users to supply their own API keys without forcing them to type long strings using a TV remote.
Once that’s solved, I’ll publish the APK here. If there’s interest, I’ll also put it on the Play Store for a small fee so people don’t need to sideload it.