Skip to content

Improve TV and WebUI interaction flows#41

Merged
ModerRAS merged 1 commit into
masterfrom
fix/user-interaction-logic
Jul 11, 2026
Merged

Improve TV and WebUI interaction flows#41
ModerRAS merged 1 commit into
masterfrom
fix/user-interaction-logic

Conversation

@ModerRAS

@ModerRAS ModerRAS commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • improve Android TV focus, search, Settings Back hierarchy, manual-match dialogs, and player exit/error behavior
  • make Media3 audio and subtitle selection functional and simplify the routine picture OSD
  • add explicit WebUI authentication, actionable empty/error states, authenticated downloads/uploads, and mobile edit scrolling
  • bump the base app version from 0.10.0 to 0.11.0 for the user-visible feature release

Validation

  • ./gradlew.bat :player-core:testDebugUnitTest :ui-tv:testDebugUnitTest
  • ./gradlew.bat :app:assembleDebug
  • npm run build in web-control/frontend
  • HK1 adb install -r and UIAutomator focus/navigation checks
  • NanoKVM 1920x1080 HDMI verification
  • Playwright auth, startup-error retry, empty-library, and 390x844 mobile edit-scroll checks

Interaction details

  • player Back closes open menus, hides controls, then requires a second bare-playback Back within two seconds to exit
  • library opens on content instead of Scan; search can be opened explicitly and closed with Back while restoring focus
  • Settings opens on Media Sources and returns from the content pane to the category menu before leaving Settings
  • WebUI 401 responses show a token gate instead of an empty library, including raw download/upload paths and token-rotation races

Summary by CodeRabbit

  • New Features
    • Added library search with localized title matching and TV-friendly focus navigation.
    • Added subtitle and audio track selection indicators.
    • Added playback retry and two-step back confirmation.
    • Added WebUI access-token sign-in, protected request handling, and clearer source-loading states.
    • Added token visibility controls and improved TV settings navigation.
  • Bug Fixes
    • Prevented manual-match dialogs from closing during active searches or applications.
    • Improved player error recovery and focus behavior.
  • Localization
    • Localized the picture menu title to Chinese.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The changes add playback track-state reporting, TV-focused search and navigation behavior, player retry and exit confirmation, manual-match dismissal guards, settings improvements, WebUI access gating, authenticated requests, and a default application version update.

Changes

Playback controls and track state

Layer / File(s) Summary
Selected track state and controller APIs
player-core/.../ExoPlaybackController.kt, player-core/.../PlaybackController.kt, ui-tv/.../PlayerViewModel.kt
Subtitle and audio selections are applied through ExoPlayer overrides and exposed as nullable state flows.
Player controls, options, and error actions
core/model/.../PlaybackUiConventions.kt, ui-tv/.../PlayerScreen.kt
Player labels, track indicators, picture options, retry behavior, and double-back exit confirmation are updated.
Playback behavior tests
ui-tv/src/test/.../player/*
Tests cover exit timing, updated options-panel wiring, and localized picture-menu labeling.

TV navigation and content discovery

Layer / File(s) Summary
Focus and directional input primitives
ui-tv/.../components/*
Cards and key handlers support directional focus callbacks, while text fields support initial focus requests.
Library search and focus flow
ui-tv/.../library/LibraryScreen.kt, ui-tv/src/test/.../LibrarySearchTest.kt
Library search filters localized titles and IDs, renders results or empty states, and routes focus between the header and content.
Dialog dismissal and settings focus
ui-tv/.../detail/*, ui-tv/.../settings/AddSourceScreen.kt
Busy manual-match dialogs ignore dismissal, settings back handling moves focus between content and sections, and access tokens can be shown or masked.

WebUI access and source workflows

Layer / File(s) Summary
Access initialization and authorization flow
web-control/frontend/src/App.vue, web-control/frontend/src/api.js
Startup access checks, token entry, unauthorized events, protected requests, and polling state are wired into the application shell.
Source and library state handling
web-control/frontend/src/App.vue, web-control/frontend/src/styles.css
Source-load failures affect empty-state actions, and narrow-screen source editing scrolls to the form panel.
Protected resource requests and access styling
web-control/frontend/src/App.vue, web-control/frontend/src/styles.css
Log and archive requests include the WebUI token, while access-gate layout styles are added.

Release metadata

Layer / File(s) Summary
Default application version
app/build.gradle.kts
The default application version changes to 0.11.0.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Viewer
  participant PlayerScreen
  participant PlayerViewModel
  participant PlaybackController
  Viewer->>PlayerScreen: select track or press retry
  PlayerScreen->>PlayerViewModel: update playback action
  PlayerViewModel->>PlaybackController: select track or replay source
  PlaybackController-->>PlayerViewModel: refreshed track state
  PlayerViewModel-->>PlayerScreen: selected indices
  PlayerScreen-->>Viewer: updated controls
Loading
sequenceDiagram
  participant App
  participant API
  participant Server
  App->>API: initialize access
  API->>Server: request application data
  Server-->>API: response or HTTP 401
  API-->>App: ready state or unauthorized event
  App-->>Viewer: show application or token gate
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related to the changes, but it is broad and doesn’t clearly state the main TV, player, and WebUI behavior updates. Use a more specific title that names the primary behavior changes, such as TV focus/navigation, player track selection, and WebUI auth handling.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/user-interaction-logic

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ModerRAS ModerRAS merged commit f7c3563 into master Jul 11, 2026
10 of 11 checks passed
@ModerRAS ModerRAS deleted the fix/user-interaction-logic branch July 11, 2026 14:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web-control/frontend/src/App.vue (1)

1941-1952: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

loadSources() should not rethrow from floating callers.
watch(activeView) and void Promise.all([loadCloudDriveAutomation(), loadLibrary(), loadSources()]) don't observe the rejection, so a failed /api/sources can still become an unhandled promise rejection. Either stop rethrowing in loadSources() or add catch/await at those call sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-control/frontend/src/App.vue` around lines 1941 - 1952, Update
loadSources() to handle the /api/sources failure without rethrowing, since its
callers watch(activeView) and the floating Promise.all invocation do not observe
rejection; preserve setting sourcesLoadFailed and resetting loading.sources in
the existing catch/finally flow.
🧹 Nitpick comments (1)
ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt (1)

75-77: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider rememberSaveable for searchQuery to survive process death.

searchQuery uses remember instead of rememberSaveable, so the typed query is lost if the system kills the app while the user is mid-search. On a TV where text entry is slow with a remote, this could be frustrating. searchActive and restoreSearchFocus are transient control states and are fine with remember.

♻️ Optional refactor
-    var searchQuery by remember { mutableStateOf("") }
+    var searchQuery by rememberSaveable { mutableStateOf("") }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt` around
lines 75 - 77, Change the searchQuery state declaration in LibraryScreen from
remember to rememberSaveable so the typed query is restored after process death.
Leave searchActive and restoreSearchFocus using remember because they remain
transient control state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt`:
- Line 904: Update the subtitle and audio track mapping logic in
ExoPlaybackController to use each Tracks.Group’s isSelected state instead of
checking isTrackSelected(0). Ensure selectedSubtitleTrackIndex and the
corresponding audio index are set whenever the group is selected, regardless of
which track within the group ExoPlayer chose.

In `@ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt`:
- Around line 244-250: Update the Down-key handler in LibraryHeader so it
requests contentFocusRequester and consumes the event only when contentState is
non-null, preserving the existing behavior otherwise while allowing the event to
propagate when no library content is attached.

---

Outside diff comments:
In `@web-control/frontend/src/App.vue`:
- Around line 1941-1952: Update loadSources() to handle the /api/sources failure
without rethrowing, since its callers watch(activeView) and the floating
Promise.all invocation do not observe rejection; preserve setting
sourcesLoadFailed and resetting loading.sources in the existing catch/finally
flow.

---

Nitpick comments:
In `@ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt`:
- Around line 75-77: Change the searchQuery state declaration in LibraryScreen
from remember to rememberSaveable so the typed query is restored after process
death. Leave searchActive and restoreSearchFocus using remember because they
remain transient control state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 14cfed90-bcd9-431e-a7b5-239fa2409ca0

📥 Commits

Reviewing files that changed from the base of the PR and between fbf4fc8 and 3836ac8.

📒 Files selected for processing (21)
  • app/build.gradle.kts
  • core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt
  • player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt
  • player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/AnimeCards.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/InitialFocus.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvKeyEvents.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvTextField.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/DramaDetailScreen.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt
  • ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt
  • ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibrarySearchTest.kt
  • ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerExitConfirmationTest.kt
  • ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerOptionsPanelFocusTest.kt
  • ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerScreenPictureMenuLabelsTest.kt
  • web-control/frontend/src/App.vue
  • web-control/frontend/src/api.js
  • web-control/frontend/src/styles.css

format = SubtitleFormat.SRT,
),
)
if (group.isTrackSelected(0)) selectedSubtitleTrackIndex = trackIndex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'androidx\.media3|media3-(common|exoplayer)|media3Version' \
  --glob '*.gradle' --glob '*.gradle.kts' --glob 'libs.versions.toml' .

rg -n 'isTrackSelected|\.isSelected' player-core ui-tv --glob '*.kt'

Repository: ModerRAS/MiruPlay

Length of output: 1589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Media3 version ==="
sed -n '1,90p' gradle/libs.versions.toml | nl -ba | sed -n '1,90p'

echo
echo "=== ExoPlaybackController selection mapping ==="
sed -n '880,930p' player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt | nl -ba

echo
echo "=== Any other uses of group selection APIs ==="
rg -n 'isTrackSelected\(|group\.isSelected|selectedSubtitleTrackIndex|selectedAudioTrackIndex' player-core ui-tv --glob '*.kt'

Repository: ModerRAS/MiruPlay

Length of output: 218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== gradle/libs.versions.toml media3 entries ==="
sed -n '1,80p' gradle/libs.versions.toml | awk 'NR>=1 && NR<=80 {print NR ":" $0}' | grep -n 'media3'

echo
echo "=== ExoPlaybackController relevant lines ==="
sed -n '896,922p' player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt | awk '{print NR ":" $0}'

echo
echo "=== Local usages of group selection APIs ==="
rg -n 'isTrackSelected\(|group\.isSelected|selectedSubtitleTrackIndex|selectedAudioTrackIndex' player-core ui-tv --glob '*.kt'

Repository: ModerRAS/MiruPlay

Length of output: 7590


🌐 Web query:

androidx.media3 Tracks.Group isSelected API docs version media3-exoplayer media3-common

💡 Result:

In the androidx.media3.common library, the Tracks.Group class provides information about a single group of tracks [1][2]. The isSelected API is used to determine the selection status of tracks within that group [3][4]. There are two primary methods related to selection in this class: 1. isSelected: This method returns a boolean indicating whether at least one track in the group is currently selected for playback [1][2]. 2. isTrackSelected(int trackIndex): This method returns a boolean indicating whether a specific track, identified by its index within the group, is selected for playback [1][2]. Important context regarding track selection: - Multiple tracks in a single group can be selected simultaneously [1]. This is common in adaptive streaming scenarios where the player selects multiple tracks of varying quality to switch between based on network conditions [1][3]. - A track is considered selected if it has been chosen for playback based on the current TrackSelectionParameters [3][4]. - While this class identifies which tracks are selected, it does not explicitly indicate which of the selected tracks is currently active (the one actually being played) [1]. Some player implementations, such as ExoPlayer, provide mechanisms to retrieve this, for example via ExoTrackSelection.getSelectedFormat [1].

Citations:


Use the group’s selected state here. Tracks.Group exposes isSelected, and checking only track 0 can leave the UI index unset when ExoPlayer selects a different track in the same group. Use group.isSelected for both subtitle and audio mapping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt`
at line 904, Update the subtitle and audio track mapping logic in
ExoPlaybackController to use each Tracks.Group’s isSelected state instead of
checking isTrackSelected(0). Ensure selectedSubtitleTrackIndex and the
corresponding audio index are set whenever the group is selected, regardless of
which track within the group ExoPlayer chose.

Comment on lines +244 to +250
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
contentFocusRequester.requestFocus()
true
} else {
false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify FocusRequester.requestFocus() behavior when not attached to a composable.
# Check the Compose UI version declared in the project.
rg -n "compose.*ui" app/build.gradle.kts ui-tv/build.gradle.kts 2>/dev/null || fd -t f "build.gradle.kts" --exec rg -l "compose" {} \;

Repository: ModerRAS/MiruPlay

Length of output: 306


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant screen and focus wiring.
file="ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt"
wc -l "$file"
sed -n '100,320p' "$file"

# Find where the content focus requester is attached.
rg -n "initialContentFocus|contentFocusRequester|requestFocus\\(" ui-tv/src/main/kotlin/com/miruplay/tv/ui/library -n

# Check the Compose version pins in the version catalog / build files.
fd -t f "libs.versions.toml" . -x sh -c 'echo "== {} =="; rg -n "compose|focus" "{}"' \;
fd -t f "build.gradle.kts" . -x sh -c 'echo "== {} =="; rg -n "compose|focus" "{}"' \;

Repository: ModerRAS/MiruPlay

Length of output: 13453


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the focus handle implementation and the content attachment site.
rg -n "rememberInitialFocusHandle|class InitialFocusHandle|fun requester|fun modifier|FocusRequester" ui-tv/src/main/kotlin -n
sed -n '360,460p' ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt

# Look for any other use of requestFocus() on potentially unattached requesters.
rg -n "requestFocus\\(" ui-tv/src/main/kotlin -n

Repository: ModerRAS/MiruPlay

Length of output: 13630


🌐 Web query:

Jetpack Compose FocusRequester.requestFocus unattached IllegalStateException documentation

💡 Result:

The IllegalStateException related to FocusRequester in Jetpack Compose, typically manifesting as "FocusRequester is not initialized," occurs when you call requestFocus on a FocusRequester instance before it has been successfully associated with a composable modifier in the UI tree [1][2][3]. Common Causes and Solutions: 1. Modifiers Not Yet Attached: This is the most frequent cause, often occurring when requestFocus is called too early—such as immediately upon entering a composition or before a parent layout (like Scaffold or a SubcomposeLayout) has completed its own layout phase and attached its children [1][2][4]. Because these layouts use subcomposition, the associated Modifier.focusRequester may not be attached when the initial effect runs [1][4]. Solution: Ensure the request happens after the UI has been composed and attached. Moving the request into a LaunchedEffect within the scope of the subcomposed container (e.g., inside the Scaffold content) often resolves this [1]. 2. Requesting Focus During Composition: You should never call requestFocus directly during the composition phase [1][2]. Solution: Focus requests must be triggered by an event or a side effect [1][5]. Use side-effect handlers like LaunchedEffect or event callbacks (e.g., in response to a user click or navigation completion) [1][6][5]. 3. Missing Modifier: The FocusRequester must be explicitly linked to a composable using the Modifier.focusRequester(focusRequester) modifier [1][7][4]. If this modifier is missing or has not been applied to a target component, the FocusRequester remains uninitialized [1][4]. Best Practices: - Always remember the FocusRequester instance using remember { FocusRequester } to maintain its state across recompositions [1][7][2]. - Ensure the Modifier.focusRequester is applied to the intended composable [6][8][9]. - If you are dealing with complex layouts or navigation, ensure the component is actually present in the hierarchy before triggering the request [1][3]. For testing scenarios, consider using appropriate delays or ensuring the test environment allows for full composition/layout cycles before the request is made [7][4].

Citations:


Guard the Down handler when no library content exists. contentFocusRequester is only attached inside LibraryContent, so in Loading/NoSources/HasSources/ScanError states requestFocus() can target an unattached requester while still consuming the D-pad event. Gate this on contentState != null (or pass hasContent into LibraryHeader) before returning true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt` around
lines 244 - 250, Update the Down-key handler in LibraryHeader so it requests
contentFocusRequester and consumes the event only when contentState is non-null,
preserving the existing behavior otherwise while allowing the event to propagate
when no library content is attached.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant