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:
parent
0d583c9660
commit
cc1c1d1c9d
7 changed files with 122 additions and 1 deletions
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
42
scripts/bridge-emulator.sh
Executable 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue