Skip to content

Tags: ngminhphuc/vrplayer

Tags

v0.9.1

Toggle v0.9.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>