From cc1c1d1c9d0effdc20b0170d1ccf7749fbb09fdd Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 01:15:51 +0000 Subject: [PATCH] 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. --- .../main/java/net/jeena/jtubetv/JTubeApp.kt | 3 ++ .../jeena/jtubetv/ui/list/ImagePrefetcher.kt | 53 +++++++++++++++++++ .../jtubetv/ui/list/PagedListViewModel.kt | 4 ++ .../jtubetv/ui/list/VideoListViewModel.kt | 11 ++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 1 + .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 9 +++- scripts/bridge-emulator.sh | 42 +++++++++++++++ 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt create mode 100755 scripts/bridge-emulator.sh diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index ec4c71a..fe13971 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -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)) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt new file mode 100644 index 0000000..e29ce93 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt @@ -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() + + fun videoThumbnails(videos: List, 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, 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt index c96c5bc..3429a76 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -27,6 +27,9 @@ abstract class PagedListViewModel : ViewModel() { protected abstract suspend fun fetch(start: Long, count: Int): Result> + /** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */ + protected open fun onChunkLoaded(chunk: List) {} + fun loadMore(force: Boolean = false) { if (inFlight || (!force && exhausted)) return inFlight = true @@ -39,6 +42,7 @@ abstract class PagedListViewModel : ViewModel() { exhausted = chunk.size < pageSize items = items + chunk loadingInitial = false + onChunkLoaded(chunk) }, onFailure = { e -> if (items.isEmpty()) { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 90264f3..33bf4aa 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -30,6 +30,17 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + override fun onChunkLoaded(chunk: List) { + 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> { return when (val s = source) { is ListSource.LocalVideos -> diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 382cdb5..b7a065d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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 { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a68bfac..5867b5a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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() { + override fun onChunkLoaded(chunk: List) { + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) + } + override suspend fun fetch(start: Long, count: Int): Result> = repository.localVideos(start, count).map { it.data } diff --git a/scripts/bridge-emulator.sh b/scripts/bridge-emulator.sh new file mode 100755 index 0000000..5fc8ed2 --- /dev/null +++ b/scripts/bridge-emulator.sh @@ -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" \ No newline at end of file