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 private set
lateinit var tokenStore: TokenStore lateinit var tokenStore: TokenStore
private set private set
var appContext: android.content.Context? = null
private set
fun init(context: Application) { fun init(context: Application) {
appContext = context
tokenStore = TokenStore(context) tokenStore = TokenStore(context)
repository = PeerTubeRepository(tokenStore, PeerTubeNetwork(tokenStore)) 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>> 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) { fun loadMore(force: Boolean = false) {
if (inFlight || (!force && exhausted)) return if (inFlight || (!force && exhausted)) return
inFlight = true inFlight = true
@ -39,6 +42,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
exhausted = chunk.size < pageSize exhausted = chunk.size < pageSize
items = items + chunk items = items + chunk
loadingInitial = false loadingInitial = false
onChunkLoaded(chunk)
}, },
onFailure = { e -> onFailure = { e ->
if (items.isEmpty()) { if (items.isEmpty()) {

View file

@ -30,6 +30,17 @@ class VideoListViewModel(
val query = MutableStateFlow("") val query = MutableStateFlow("")
private var activeSearch: String? = null 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>> { override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
return when (val s = source) { return when (val s = source) {
is ListSource.LocalVideos -> 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 } userChannels = (userChannels + mine).distinctBy { it.name to it.host }
localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } localChannels = rest.filter { it.host == host }.sortedBy { it.displayName }
followedChannels = 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) { } catch (e: Exception) {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } finally {

View file

@ -65,7 +65,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private fun fetchSubs() { private fun fetchSubs() {
viewModelScope.launch { 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() }.awaitAll()
continueItems = items continueItems = items
continueLoading = false 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. */ /** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() { 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>> = override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
repository.localVideos(start, count).map { it.data } repository.localVideos(start, count).map { it.data }

42
scripts/bridge-emulator.sh Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# bridge-emulator.sh — (host-side) expose the emulator's adbd to the LAN so agents
# inside the container/other machines can use it: binds :5557 -> 127.0.0.1:5601.
# The emulator console transport (emulator-5600) stays local-only; this bridges the
# adbd TCP port 5601.
set -euo pipefail
ADBD_PORT=5601
BRIDGE_PORT=5557
# start emulator if not running
ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb"
if ! "$ADB" devices | grep -q "emulator-5600.*device"; then
echo "emulator-5600 not running — starting it…"
ADB_VENDOR_KEYS="$HOME/.android/adbkey" setsid nohup \
"${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/emulator/emulator" -avd jtubesc -port 5600 \
-no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim \
> /tmp/jtube-emu.log 2>&1 < /dev/null &
echo "waiting for boot (log: /tmp/jtube-emu.log)…"
for i in $(seq 1 60); do
ok=$("$ADB" -s emulator-5600 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
[ "$ok" = "1" ] && { echo "BOOTED"; break; }
sleep 5
done
fi
# keep JTube TV installed + running
APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk"
if [ -f "$APK" ]; then
"$ADB" -s emulator-5600 install -r "$APK" >/dev/null && echo "JTube TV installed"
"$ADB" -s emulator-5600 shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started"
fi
# the bridge (bind all LAN addresses)
if ! ss -tln 2>/dev/null | grep -q ":$BRIDGE_PORT"; then
setsid nohup socat TCP-LISTEN:$BRIDGE_PORT,bind=0.0.0.0,fork,reuseaddr \
TCP:127.0.0.1:$ADBD_PORT > /tmp/jtube-bridge.log 2>&1 < /dev/null &
echo "bridge: :$BRIDGE_PORT -> 127.0.0.1:$ADBD_PORT (pid $!)"
fi
echo "From this host: adb connect localhost:$BRIDGE_PORT"
echo "From other machines: adb connect rutherford:$BRIDGE_PORT"