Tags: ngminhphuc/vrplayer
Tags
Merge pull request #16 from ngminhphuc/devin/1777397343-reland-all-sp… …rints * feat(stage1-sprint2): world-space picker (Compose-to-Surface) + per-file resume - VrPickerScreen.kt: dark-themed Compose list of MediaStore videos, 88 dp rows, 22 sp+ fonts, designed for 1.2x0.8 m world-space panel. - PickerSurfaceHost: ComposeView attached to invisible WindowManager window, locks Surface canvas every 33 ms to push frames to native GL_TEXTURE_EXTERNAL_OES. - picker_quad.{h,cpp}: flat quad at z=-1.4m + ray-vs-plane hit-test. Trigger while picker visible -> hit-test -> inject MotionEvent + close picker. Menu tap toggles picker visibility (acquires the picker surface lazily on first open). - XrActivity rewires: scan MediaStore on resume, load last-played file with resume position, save currentPosition every 5 s plus a flush in onPause. - ResumeStore: SharedPreferences-backed per-file resume with '_last_played' pointer. - video_bridge: 6 new JNI methods for picker surface lifecycle. - AndroidManifest: READ_MEDIA_VIDEO (33+) + legacy READ_EXTERNAL_STORAGE. Docs: docs/dev/world-space-ui.md, docs/reports/stage1-sprint2-report.md. Build verified: assembleQuestDebug + ktlintCheck pass. Runtime on Quest hardware not yet verified (no device on CI host). Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint2): address Devin Review findings 1. PickerSurfaceHost.releaseSurface: views attached via WindowManager.addView have a ViewRootImpl parent, not a ViewGroup. The previous '(parent as? ViewGroup).removeView' silently failed and leaked the window. Use WindowManager.removeViewImmediate() inside a runCatching. 2. video_bridge.cpp::getPickerTransformMatrix now writes the identity fallback at the very top, before the early-return checks, so the caller never sees uninitialised stack data. 3. video_bridge.cpp::detach now also resets all sprint-2 picker jmethodIDs and the cached sTextureId / sPickerTexId so re-attach on activity re-creation cannot hand Kotlin a stale GL texture handle. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint2): post WindowManager ops to main thread + shader leak - PickerSurfaceHost.acquirePickerSurface is invoked from the native render thread via JNI, but ComposeView.setContent and WindowManager.addView must run on the Android UI thread. Without this the picker silently fails (the catch swallows CalledFromWrongThreadException). Post the attach (and the symmetrical removeView in releaseSurface) onto the main Looper. - picker_quad.cpp::init: when the fragment shader fails to compile but the vertex shader succeeded (or vice versa) the surviving handle was leaked. Now both are deleted before returning false. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint2): picker hit-test uses correct hand's aim triggerPressedEdge fires for either hand, but the picker hit-test was unconditionally preferring the right controller's aim whenever its tracking is valid (which is always in normal use). If the user aimed the left controller at a row and pulled the left trigger, we'd ray-test from the right controller's position — selecting the wrong file or missing the picker entirely. Split triggerPressedEdge into triggerLeftEdge / triggerRightEdge so xr_session can pick the correct aim. The combined edge is kept for existing callers (play/pause). Reported by Devin Review on PR #4. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage1-sprint3): URL streaming + sleep timer + proximity + sideload docs - Tab Network trong picker: BasicTextField + button Play URL + LazyColumn lich su (UrlHistoryStore, SharedPreferences, max 32). - playback/SleepTimer.kt: Handler.postDelayed pause player; tab Settings chon 0/15/30/60m. - playback/ProximityAutoPause.kt: SensorManager TYPE_PROXIMITY -> pause khi thao headset, resume khi deo lai. - XrActivity.onKeyDown chan VOLUME_UP/DOWN -> volumeDelta(+/-0.1). - VrPickerScreen refactor: enum PickerTab { Local, Network, Settings }, TabChip row. - docs/sideload.md (VN + EN) tu Developer Mode den controls. Build verified: assembleQuestDebug + ktlintCheck pass. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint3): reset ProximityAutoPause.wasNear on start Without resetting, after onPause()/onResume() if the headset was already off, wasNear remains false and the first far reading no longer triggers onUnmounted -> player keeps playing with headset off. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint3): drop redundant URL submit in picker Both onSubmit() and onPickUrl() were called for the 'Phát URL' button. playUrl() (triggered by onPickUrl) already trims the URL and pushes it to UrlHistoryStore, so onSubmit() was creating a duplicate untrimmed entry whenever the input had leading or trailing whitespace. Reported by Devin Review on PR #5. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint3): move pausedByProximity into ProximityAutoPause The pausedByProximity flag was captured as a closure local in XrActivity, so it survived activity lifecycle transitions. Sequence that misbehaved: 1) headset off-head while playing -> flag = true, pause 2) onPause -> stop sensor; flag still true 3) onResume -> start sensor; flag *still* true 4) sleep timer fires -> player paused (not by us) 5) headset briefly lifted then put back -> remount sees flag true -> resume player, defeating the sleep-timer pause. Reset the flag inside ProximityAutoPause.start() so each lifecycle restart gets a clean baseline. Refactored callbacks to take (isPlaying, pausePlayer, resumePlayer) lambdas so all 'we paused it' state lives in the sensor class. Reported by Devin Review on PR #5. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage1-sprint3): proximity clearPausedFlag on sleep timer + manual pause When proximity sets pausedByUs=true and another component (sleep timer or manual togglePlayPause) subsequently pauses the player, a remount would auto-resume and override the intentional pause. Add a public clearPausedFlag() and call it from both code paths so any deliberate pause is preserved across headset-on/off transitions. Reported by Devin Review on PR #5. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage2-sprint1): 360 equirect + 180 hemisphere + auto-detect + snap-front - cpp/sphere.{h,cpp}: inside-out 48x96 sphere, shader samplerExternalOES. Two pre-built VAOs: full 360 (yaw 0..2pi) and front hemisphere (yaw -pi/2..pi/2) sharing the equirect UV mapping. - cpp/native_calls.cpp: first Kotlin->native JNI bridge in the project. Exports nativeSetProjection / nativeSnapFront / nativeRotateYaw. - gl_renderer.cpp: renderEye dispatches to Screen (cylinder) when mode is OFF, otherwise Sphere. - playback/ProjectionDetector.kt: filename heuristics (_360/_eq/vr360 vs _180/vr180) + best-effort MediaExtractor 'spatial-format' on 33+. - XrActivity.applyProjectionFor() called from playEntry and playUrl; setProjectionOverride() for user manual choice. - VrPickerScreen Settings tab: 4 projection chips (Auto/Cinema/360/180) + Snap front button. Build: assembleQuestDebug + ktlintCheck pass. Hardware verification deferred. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage2-sprint1): sphere yaw alignment + extractor leak + resume projection 3 issues caught by Devin Review: 1. Sphere meshes were rotated 90° from OpenXR forward (-Z). The vertex formula gives forward = -Z = yaw 3π/2, so equirect u=0.5 needs to be at that yaw. Shift 360 range to [π/2, π/2+2π], 180 to [-π, 0]. 2. ProjectionDetector.guessByMetadata leaked a MediaExtractor when setDataSource() throws (only the second try block had a finally). 3. loadInitialMedia did not call applyProjectionFor, so a 360 video resumed from resumeStore played in cinema mode until manual switch. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage2-sprint1): atomic sMode/sYawOffsetRad in sphere.cpp These globals are written from JNI (UI thread) via Sphere::setMode / Sphere::setYawOffsetDeg and read from the native render thread in Sphere::mode() and Sphere::draw(). Plain non-atomic access is UB in C++17; with LTO the compiler may cache the reads. Switch to std::atomic with acquire/release semantics. Reported by Devin Review on PR #6. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage2-sprint2): stereo SBS/TB via per-eye UV crop - cpp/stereo.{h,cpp}: 5-mode enum (Mono/SBS_LR/SBS_RL/TB_LR/TB_RL), uvScaleOffset(eyeIndex) returning (scaleU,scaleV,offU,offV). - cpp/gl_renderer.cpp: renderEye gains an eyeIndex; composes a per-eye UV crop matrix into texMatrix before delegating to Screen / Sphere. Approach avoids touching Screen/Sphere shaders or signatures. - cpp/native_calls.cpp: export nativeSetStereo(int). - playback/StereoMode.kt: filename heuristic detector (_sbs/_lr/_tb/_ou variants). Order: longer prefixes first. - XrActivity.applyStereoFor chained off applyProjectionFor; manual override via setStereoOverride. - VrPickerScreen Settings tab: stereo selector (6 chips, 2 rows). Subtitle render-to-texture deferred to stage 3 sprint 1 along with comfort menu — see report for rationale. Build: assembleQuestDebug + ktlintCheck pass. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage2-sprint2): atomic Stereo::sMode Apply same cross-thread atomic pattern used for sphere globals to Stereo::sMode (written from JNI thread, read from render thread). Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage3-sprint1): SMB/NAS streaming via smbj + EncryptedSharedPreferences - smbj 0.14.0 + androidx.security-crypto for encrypted password store. - SmbServer / SmbServerStore (JSON list + per-server pw_<id> entries in EncryptedSharedPreferences, AES-256-GCM at rest). - SmbBrowser: smbj client wrapper, lazy connect/auth/connectShare, list() + open() + close(). - SmbDataSource: Media3 BaseDataSource that parses smb:// URIs, looks up creds in store, reads via smbj File.read(buf, pos, off, n). - SmbAwareDataSource: wrapper that picks SmbDataSource for smb:// and defaultDelegate for everything else. - XrActivity wires SmbAwareDataSource.Factory into ExoPlayer via DefaultMediaSourceFactory; smb URIs flow through playUrl(). - Picker: 4th tab 'Smb' with add-server form, server list with remove, path entry + Play button. - app/build.gradle.kts pickFirsts for OSGI-INF MANIFEST + module-info to resolve smbj+bcprov-jdk18on / jspecify duplicate META-INF. Report: docs/reports/stage3-sprint1-report.md Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage3-sprint1): SmbDataSource.getUri returns stored DataSpec uri Previously interpolated SmbBrowser instance which produces toString garbage (e.g. smb://...SmbBrowser@1a2b3c). Store dataSpec.uri at open() and return it from getUri(); reset in close(). Reported by Devin Review on PR #8. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage3-sprint1): case-insensitive smb scheme check + cleanup half-built SMB connection on auth failure - SmbDataSource: accept smb:// URIs case-insensitively (matches SmbAwareDataSource routing) and throw IOException instead of IllegalArgumentException so Media3 can surface it cleanly. - SmbBrowser.ensureShare: build conn/session/share locally and only commit to fields once all three succeed; on failure, close any partially-built resources before rethrowing. Prevents leaking a Connection on every ExoPlayer retry when authenticate() fails. Reported by Devin Review on PR #8. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage3-sprint1): clear stale SmbTab selection on server removal Devin Review caught: clicking the X button to remove an SMB server kept the local 'selected' Compose state pointing at the now-deleted server. The Play button re-enables for that stale reference, and clicking it constructs a smb:// URI whose host/share no longer resolve in SmbServerStore — SmbDataSource then throws 'Unknown SMB server'. Clear selected = null in the same callback before invoking onRemove so the Play button disables on the same recompose. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage3-sprint1): build SMB URI via Uri.Builder so reserved chars are encoded Devin Review caught: 'smb://${host}/${share}/$cleanPath' string interpolation breaks when filenames contain # (parsed as fragment), ? (query), or % (percent escape). 'movie #2.mkv' becomes path 'movie ' with fragment '2.mkv' on the SmbDataSource side, so it opens the wrong file or fails with not-found. Build the URI through Uri.Builder.appendPath(...) on each segment instead. Round-trips correctly because SmbDataSource.open() reads back via uri.pathSegments which decodes percent-encoded names. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage3-sprint1): case-insensitive SMB host/share match in SmbDataSource Devin Review caught: SMB protocol treats hostnames and share names as case-insensitive (RFC 952 / MS-SMB2). The smb-tab flow always builds URIs from stored values so it round-trips, but the Network tab lets a user type 'smb://MyNAS/Videos/...' directly. If the server was registered as host='mynas' share='videos', SmbDataSource fails with 'Unknown SMB server' even though it is configured. Use String.equals(..., ignoreCase=true) for the lookup. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage3-sprint2): hand-tracking pinch + 4 environment presets Native: - xr_session: opt-in to XR_EXT_hand_tracking when runtime supports it. - HandTracking class: dlsym xrCreateHandTrackerEXT et al, run two trackers (L/R), pinch hysteresis (25mm enter, 35mm release), edge latch consumed by XrSession::processInput. - Pinch edges fold into XrInput trigger edges; aim source switches to hand index-tip pose when pinch fires (controller still wins when no hand is pinching). - Skybox: 4 procedural modes (BlackVoid, ModernCinema, DriveIn, Space) via shader branching on uMode. Globals atomic-stored. - nativeSetEnvironment JNI. Kotlin: - EnvironmentMode enum (raw + label). - XrActivity persists choice in vrplayer_env SharedPreferences, pushes to native on configurePicker(). - PickerSurfaceHost adds environment state + onEnvironmentChange callback. - VrPickerScreen: Settings tab gets 'Môi trường' row of 4 chips. Hand tracking is best-effort: a missing extension or a runtime that returns failure leaves HandTracking disabled and controllers continue to work normally. Report: docs/reports/stage3-sprint2-report.md Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage3-sprint3): A-B loop + per-file bookmarks + Playback tab in picker - ABLoop: simple state machine, polled from resume-writer loop (250 ms tick) to seek back to A when position crosses B; cleared on file change. - BookmarkStore: per-file JSON-backed bookmarks (vrplayer_bookmarks pref). Persists across sessions; key = path / URI. - New 'Playback' tab in VrPickerScreen exposes A/B set, clear, add/remove bookmark, and seek to bookmark. - XrActivity wires the new picker callbacks; resetPerFileState() refreshes bookmarks and clears A-B loop on each playEntry/playUrl. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * docs(stage3-sprint3): A-B loop + bookmarks report, CHANGELOG, release signing template Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage3-sprint3): hydrate bookmarks on resume + correct A-B post-seek save Two issues caught by Devin Review on PR #10: 1. loadInitialMedia() restores the resume position and projection but never called resetPerFileState(), so the Playback tab showed an empty bookmark list on cold-start until the user re-picked the file. Fix: invoke resetPerFileState(it) in the same resumePath let block that already drives applyProjectionFor. 2. The resume-writer captures pos once and used the same pre-seek value for both the A-B loop check and the resume save. On a tick that triggers the loop, the persisted position lands past B; on next launch the player would resume past the loop and skip A. Fix: keep the seek target from seekTargetIfPastB and save that target instead of the stale pos when the loop fired. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage4-sprint1): About tab + player status banner + privacy policy + CI About tab in the world-space picker credits NextPlayer (GPLv3 upstream) and lists the bundled Apache-2.0 libraries — required attribution that the App Lab review checks for. PlayerStatus sealed interface + Player.Listener wiring surfaces ExoPlayer Buffering / Ended / Error state to the picker as a banner. Previously a network failure or codec error left the user with a silent black quad and no clue what happened. docs/PRIVACY.md is the App Lab privacy policy boilerplate. No analytics, no ad SDK, no backend; everything stays on-device. Lists each SharedPreferences key and each runtime permission with rationale. .github/workflows/build.yml runs ktlintCheck + assembleQuestDebug on every PR and uploads the APK artifact for 14 days. Chosen versions (JDK 17, build-tools 34.0.0, NDK 25.2.9519653, CMake 3.22.1) match what app/build.gradle.kts declares. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage4-sprint2): external subtitle linking via SAF + Player.Listener.onCues Sub-feature: lets the user attach an external SRT/VTT/ASS/TTML sidecar to the file currently playing. SubtitleStore persists path -> subtitle URI in plain SharedPrefs (same shape as BookmarkStore / ResumeStore). buildMediaItem() replaces MediaItem.fromUri at all three call sites (playUrl, playEntry, loadInitialMedia). When the store has a sidecar for the path, the helper attaches a SubtitleConfiguration with the MIME guessed from the file extension and sets SELECTION_FLAG_DEFAULT so ExoPlayer auto- selects the track. Player.Listener.onCues joins the cue group text and pushes it to PickerSurfaceHost.setSubtitleCue, displayed in a new 'Phụ đề' tab in the world-space picker. The tab also exposes 'Chọn file' (SAF ACTION_OPEN_DOCUMENT) and 'Bỏ phụ đề'. takePersistableUriPermission keeps the chosen URI valid across process restarts. Note: cue is only visible while picker is open. Sprint 4-3 will add a native subtitle quad rendered head-locked below the cinema screen so cues are visible during normal playback. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage4-sprint2): push subtitle URI to picker on add/remove (Devin Review) Without this the Subtitle tab keeps showing stale URI after the user picks or clears a subtitle for the file currently playing since resetPerFileState only fires on file change. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage4-sprint2): preserve playWhenReady on subtitle change (Devin Review) setExternalSubtitle is a mid-playback swap of the same file, not a fresh start. Hardcoding playWhenReady=true would resume a paused video the moment the user picks or clears a sidecar. Capture the current value before re-prepare and restore it. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage4-sprint2): resolve subtitle MIME from display name not URI (Devin Review) substringAfterLast('.') on a content:// URI can match dots in the authority (e.g. com.android.providers.downloads.documents) instead of the file extension. Query OpenableColumns.DISPLAY_NAME via ContentResolver to get the original filename and split on that; fall back to the URI string only if the query fails. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage4-sprint3): head-locked subtitle quad + Compose-to-Surface host Native: - subtitle_quad.{h,cpp}: 2.0x0.3m flat quad, Y=0.6, Z=-3.0, alpha-blend - video_bridge: 5 new JNI methods + sSubtitleTexId - native_calls: nativeSetSubtitleVisible(boolean) for Kotlin->native visibility toggle - gl_renderer: SubtitleQuad init/shutdown/render pass after picker Kotlin: - SubtitleSurfaceHost: mini Compose-to-Surface, 1024x192, transparent background, centered Text(cue) with 32sp white SemiBold - XrActivity: subtitleHost lazy, 5 JNI-callable methods, onCues listener pushes text + flips native visibility, resetPerFileState + setExternalSubtitle clear cue, onDestroy release host Docs: - stage4-sprint3.md (plan) - reports/stage4-sprint3-report.md Build: app:assembleQuestDebug + ktlintCheck SUCCESSFUL. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage4-sprint3): GL texture create on render thread + premultiplied blend (Devin Review) Two bugs caught by Devin Review: 1. nativeSetSubtitleVisible called requestSubtitleSurface() from the JNI/main thread, which has no GL context. glGenTextures was silently no-op and sSubtitleTexId stayed 0 forever, so the quad never rendered. Move lazy creation to GlRenderer::renderEye (render thread owns the GL context), mirror what the picker does. 2. SubtitleQuad used (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) but Surface.lockCanvas produces premultiplied alpha, which would cause dark halos around antialiased text edges. Use (GL_ONE, GL_ONE_MINUS_SRC_ALPHA). Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage4-sprint4): subtitle font size + vertical offset preferences Persistence: - SubtitlePrefsStore (plain SharedPreferences, no encryption needed for cosmetic settings). Font size 20-56 sp, offset -0.6 to 0.4 m. Native: - subtitle_quad: setVerticalOffset(float). model matrix translate(0, sOffsetY, 0) baked into MVP per draw call. - native_calls: nativeSetSubtitleVerticalOffset JNI export. Kotlin: - SubtitleSurfaceHost: surface 192->256 px high, fontSizeSp Compose state recomposes Text with new size. - PickerSurfaceHost: two new states + setters + change callbacks. - VrPickerScreen.SubtitleTab: font size chips (24/32/40/48 sp) + vertical offset chips (-0.50/-0.25/0/+0.25 m). - XrActivity: subtitlePrefs store, restore on launch, wire callbacks. Build: app:assembleQuestDebug + ktlintCheck SUCCESSFUL. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * feat(stage4-sprint5): auto-scan sidecar SRT/VTT/ASS/SSA/TTML for local files Resolution order in buildMediaItem: 1. Explicit SubtitleStore entry (SAF picker user choice). 2. Auto-detected sidecar in same dir for local file paths. 3. No subtitle. autoScanSidecar: - Accepts file:// URIs and bare absolute paths. - Skips content:// (SAF) and smb:// — sprint 4-6 territory. - Loops srt/vtt/ass/ssa/ttml, returns first existing readable match. resetPerFileState pushes the auto-detect result into the Subtitle tab so the UI confirms the pickup. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(stage4-sprint5): clearing subtitle no longer re-attached by auto-scan (Devin Review) When the user clicks 'Bỏ phụ đề' on a local file with a sidecar sitting next to it, setExternalSubtitle(null) cleared SubtitleStore but buildMediaItem(key) immediately re-discovered the sidecar via autoScanSidecar and re-attached it — making the clear button a no-op. Fix: when subtitleUri == null in setExternalSubtitle (the explicit clear path), build MediaItem.fromUri(key) directly, bypassing auto-scan for that single playback. resetPerFileState still calls auto-scan on the next file open, matching the documented behavior: 'Bỏ phụ đề → set null vào store, lần mở sau auto-detect lại'. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * build: bump Gradle JVM heap to 4 GB to fix CI D8 OOM CI's D8 dex merger is hitting java.lang.OutOfMemoryError on the :app:mergeExtDexQuestDebug task because the Quest flavor pulls in a larger native blob (libvrplayer.so + libopenxr_loader.so) than the default 2 GB heap can handle while D8 holds dex archives in memory. Bumping -Xmx 2048m -> 4096m and adding -XX:MaxMetaspaceSize=1024m to give D8 enough headroom. GitHub-hosted ubuntu-24.04 runners have 16 GB RAM so 4 GB is comfortably below the limit. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix: SmbAwareDataSource null-scheme NPE + per-frame texture update (Devin Review) 1. SmbAwareDataSource crashed with NPE when a DataSpec's URI scheme was null (e.g. bare path 'Movies/clip.mp4') because String!.equals was called on the unboxed nullable. Flip the equals call so the literal 'smb' is the receiver, which short-circuits null safely. 2. Hoist VideoBridge::updateTexImage / picker / subtitle SurfaceTexture updates out of GlRenderer::renderEye into a new beginFrame() that runs once per OpenXR frame. Previously the per-eye loop called updateTexImage twice per frame; if ExoPlayer posted a new decoded frame between left and right eye renders, the eyes would display different frames, causing binocular rivalry / VR comfort issues. Texture matrices are cached in static state and reused per eye. Stereo UV crop still composes per-eye against the cached matrix. Build: app:assembleQuestDebug + ktlintCheck pass. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> * fix(subtitle_quad): atomic sVisible/sOffsetY for cross-thread access (Devin Review) sVisible (bool) and sOffsetY (float) were written from the JNI/main thread by native_calls and read from the native render thread, with no synchronization. Promote both to std::atomic with acquire/release memory ordering, matching the pattern used by Sphere::sMode, Stereo::sMode, and Skybox::sMode. ARM64 aligned bool/float stores are hardware-atomic but the compiler is free to assume no race and cache the value in a register, never seeing the JNI thread's update — atomics make the cross-thread visibility explicit. Build: app:assembleQuestDebug + ktlintCheck pass. Co-Authored-By: Sjkwje Pakrbdn <ngminhphuc@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Sjkwje Pakrbdn <ngminhphuc@gmail.com>