scripts: bridge-emulator.sh (host) + image prefetching

Perf work for the TV FPS complaint: every page that loads from the API
also warms Coil's disk/memory caches (video thumbnails at the same
480x270 the card renders, channel banners/avatars) so rows scroll
without popping in over the network. Prefetch keys match render keys
exactly so cache hits are guaranteed.

bridge-emulator.sh runs OUTSIDE the container on the emulator host:
boots jtubesc if needed (ADB_VENDOR_KEYS whitelists the host adb key),
installs/starts JTube TV and binds :5557 -> adbd 5601 so the container
and LAN can drive it. Run it once in your rutherford shell.
This commit is contained in:
Jeena 2026-10-11 01:15:51 +00:00
parent 0d583c9660
commit cc1c1d1c9d
7 changed files with 122 additions and 1 deletions

View file

@ -13,8 +13,11 @@ object AppGraph {
private set
lateinit var tokenStore: TokenStore
private set
var appContext: android.content.Context? = null
private set
fun init(context: Application) {
appContext = context
tokenStore = TokenStore(context)
repository = PeerTubeRepository(tokenStore, PeerTubeNetwork(tokenStore))
}

View file

@ -0,0 +1,53 @@
package net.jeena.jtubetv.ui.list
import android.content.Context
import coil.imageLoader
import coil.request.ImageRequest
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.api.Avatar
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.data.api.VideoDto
/**
* Warms Coil's caches for images that are already loaded from the API but not yet
* visible further down a row/list, so they pop in instantly instead of popping in
* over the network while scrolling.
*
* Uses the same request shape (size) as the visible [VideoCard]/[ChannelCard]
* renderers so the memory cache keys match exactly.
*/
object ImagePrefetcher {
private val enqueued = HashSet<String>()
fun videoThumbnails(videos: List<VideoDto>, context: Context = AppGraph.appContext!!) {
for (video in videos) {
val path = video.thumbnailPath?.takeIf { it.isNotBlank() } ?: continue
enqueue(context, AppGraph.repository.absoluteUrl(path), 480, 270)
}
}
fun channelArt(channels: List<VideoChannelDto>, context: Context = AppGraph.appContext!!) {
for (channel in channels) {
val banner = channel.banners.maxByOrNull { it.width }
if (banner != null) enqueue(context, absolute(banner), 480, 270)
val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) enqueue(context, absolute(avatar), 80, 80)
}
}
private fun absolute(a: Avatar): String =
(a.fileUrl ?: a.path).let {
if (it.startsWith("http")) it
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
}
private fun enqueue(context: Context, url: String, width: Int, height: Int) {
if (!enqueued.add(url)) return
val request = ImageRequest.Builder(context)
.data(url)
.size(width, height)
.build()
context.imageLoader.enqueue(request)
}
}

View file

@ -27,6 +27,9 @@ abstract class PagedListViewModel<T> : ViewModel() {
protected abstract suspend fun fetch(start: Long, count: Int): Result<List<T>>
/** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */
protected open fun onChunkLoaded(chunk: List<T>) {}
fun loadMore(force: Boolean = false) {
if (inFlight || (!force && exhausted)) return
inFlight = true
@ -39,6 +42,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
exhausted = chunk.size < pageSize
items = items + chunk
loadingInitial = false
onChunkLoaded(chunk)
},
onFailure = { e ->
if (items.isEmpty()) {

View file

@ -30,6 +30,17 @@ class VideoListViewModel(
val query = MutableStateFlow("")
private var activeSearch: String? = null
override fun onChunkLoaded(chunk: List<VideoListItem>) {
ImagePrefetcher.videoThumbnails(
chunk.mapNotNull {
when (it) {
is VideoListItem.Video -> it.video
is VideoListItem.HistoryEntry -> it.history.video
}
}
)
}
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
return when (val s = source) {
is ListSource.LocalVideos ->

View file

@ -109,6 +109,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
userChannels = (userChannels + mine).distinctBy { it.name to it.host }
localChannels = rest.filter { it.host == host }.sortedBy { it.displayName }
followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName }
net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all)
} catch (e: Exception) {
error = e.message ?: "Could not load channels"
} finally {

View file

@ -65,7 +65,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private fun fetchSubs() {
viewModelScope.launch {
subItems = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
subItems = subs
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
}
}
@ -108,6 +110,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
}.awaitAll()
continueItems = items
continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
}
}
@ -122,6 +125,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
/** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
override fun onChunkLoaded(chunk: List<VideoDto>) {
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
}
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
repository.localVideos(start, count).map { it.data }