From f5c1776e421d2b01a725f59d09389c1603605470 Mon Sep 17 00:00:00 2001 From: wkumik Date: Sat, 13 Jun 2026 14:40:28 +0200 Subject: [PATCH 1/7] Add Recordings screen: DJI-style download & preview (primary mode) Onboard drone recordings become the app's primary feature; the live USB viewer moves to a secondary menu entry. - Material 3 dark "album" UI (RecordingsActivity): 16:9 thumbnails, duration badge, size/date, per-item download progress and actions. - RubyShell: SSH client over the drone's dropbear. Uses exec + `cat`/`stat` (NOT SFTP) because the SigmaStar dropbear build ships no SFTP/SCP subsystem. - Lossless .ts -> .mp4 remux on-device via MediaExtractor + MediaMuxer (no re-encode) for reliable seeking and shareable mp4. - Native HEVC playback (VideoView + MediaController), first-frame thumbnails, share sheet via FileProvider, delete from drone/phone. - WifiJoiner (WifiNetworkSpecifier on API 29+, panel fallback) staged for the upcoming drone "phone-transfer mode" AP; not yet wired into Connect. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 +- ROADMAP.md | 16 + app/build.gradle | 13 +- app/proguard-rules.pro | 5 + app/src/main/AndroidManifest.xml | 38 +- .../viewer/player/PlaybackActivity.java | 96 ++++ .../com/rubyfpv/viewer/recordings/Format.java | 38 ++ .../rubyfpv/viewer/recordings/Recording.java | 40 ++ .../viewer/recordings/RecordingsActivity.java | 497 ++++++++++++++++++ .../viewer/recordings/RecordingsAdapter.java | 176 +++++++ .../rubyfpv/viewer/recordings/Remuxer.java | 105 ++++ .../rubyfpv/viewer/recordings/RubyShell.java | 189 +++++++ .../com/rubyfpv/viewer/recordings/Thumbs.java | 63 +++ .../rubyfpv/viewer/recordings/WifiJoiner.java | 91 ++++ app/src/main/res/drawable/badge_bg.xml | 5 + app/src/main/res/drawable/ic_arrow_back.xml | 5 + app/src/main/res/drawable/ic_check_circle.xml | 6 + app/src/main/res/drawable/ic_delete.xml | 6 + app/src/main/res/drawable/ic_download.xml | 6 + app/src/main/res/drawable/ic_live_tv.xml | 6 + app/src/main/res/drawable/ic_more_vert.xml | 6 + app/src/main/res/drawable/ic_play_arrow.xml | 6 + app/src/main/res/drawable/ic_refresh.xml | 6 + app/src/main/res/drawable/ic_settings.xml | 6 + app/src/main/res/drawable/ic_share.xml | 6 + app/src/main/res/drawable/ic_videocam.xml | 6 + app/src/main/res/drawable/ic_wifi.xml | 6 + app/src/main/res/drawable/play_scrim.xml | 5 + app/src/main/res/drawable/status_dot.xml | 5 + .../main/res/drawable/thumb_placeholder.xml | 16 + app/src/main/res/layout/activity_playback.xml | 25 + .../main/res/layout/activity_recordings.xml | 130 +++++ app/src/main/res/layout/dialog_connection.xml | 99 ++++ app/src/main/res/layout/item_recording.xml | 158 ++++++ app/src/main/res/menu/playback.xml | 9 + app/src/main/res/menu/recordings.xml | 22 + app/src/main/res/values/colors.xml | 25 + app/src/main/res/values/strings.xml | 49 +- app/src/main/res/values/styles.xml | 33 +- app/src/main/res/xml/file_paths.xml | 7 + gradle/libs.versions.toml | 10 + 41 files changed, 2053 insertions(+), 16 deletions(-) create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/Format.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java create mode 100644 app/src/main/res/drawable/badge_bg.xml create mode 100644 app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_check_circle.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_download.xml create mode 100644 app/src/main/res/drawable/ic_live_tv.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_play_arrow.xml create mode 100644 app/src/main/res/drawable/ic_refresh.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/drawable/ic_share.xml create mode 100644 app/src/main/res/drawable/ic_videocam.xml create mode 100644 app/src/main/res/drawable/ic_wifi.xml create mode 100644 app/src/main/res/drawable/play_scrim.xml create mode 100644 app/src/main/res/drawable/status_dot.xml create mode 100644 app/src/main/res/drawable/thumb_placeholder.xml create mode 100644 app/src/main/res/layout/activity_playback.xml create mode 100644 app/src/main/res/layout/activity_recordings.xml create mode 100644 app/src/main/res/layout/dialog_connection.xml create mode 100644 app/src/main/res/layout/item_recording.xml create mode 100644 app/src/main/res/menu/playback.xml create mode 100644 app/src/main/res/menu/recordings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/README.md b/README.md index a2ef922..4d9b90d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,41 @@ # RubyFPV Android Viewer -Android app that displays the live video feed from a [RubyFPV](https://rubyfpv.com) ground station over USB-C tethering. +Android companion app for [RubyFPV](https://rubyfpv.com). Two modes: -Plug your phone into the Ruby ground station, enable USB tethering, and see exactly what the pilot sees. +1. **Recordings** (primary) — DJI-style transfer of onboard HEVC recordings off the + drone over its Wi-Fi, with on-device preview and sharing. +2. **Live view** (secondary) — the live H.264 feed from a Ruby ground station over + USB-C tethering. -## How it works +## Recordings — download & preview + +When the drone is in **phone-transfer mode** (its radio comes up as a Wi-Fi AP), the +app connects over SSH and lets you browse the onboard SD card: + +1. Join the drone's Wi-Fi and tap **Connect** +2. Browse recorded clips with thumbnails, duration, size and date +3. **Download** a clip — it transfers over Wi-Fi and is losslessly rewrapped from + `.ts` to `.mp4` on-device (zero re-encode) for reliable seeking and sharing +4. **Play** it natively (hardware HEVC decode) or **Share** to anything + +Transfer uses an SSH `exec` + `cat` stream rather than SFTP, because the drone's +dropbear build ships no SFTP/SCP subsystem. + +## Live view — how it works 1. Phone connects to Ruby ground station via USB cable 2. USB tethering is enabled on the phone (creates a network link) 3. Ruby detects the phone and sends raw H.264 video over UDP port 5001 4. The app decodes and displays the video in fullscreen with minimal latency +(Open it from the **⋮ → Live view (USB)** menu on the Recordings screen.) + ## Features -- Hardware H.264 decoding via Android MediaCodec -- Fullscreen landscape display -- Auto-recovery on stream loss (3-second watchdog) -- Stream stats overlay (tap screen to toggle): bitrate, packet rate, NAL rate +- DJI-style recordings album: thumbnails, lossless `.ts`→`.mp4` remux, share sheet +- Hardware H.264 / HEVC decoding via Android MediaCodec / MediaPlayer +- Fullscreen landscape live display, auto-recovery on stream loss (3-second watchdog) +- Live stream stats overlay (tap screen to toggle): bitrate, packet rate, NAL rate - Minimal latency — designed for FPV use ## Requirements diff --git a/ROADMAP.md b/ROADMAP.md index ce8359d..794045c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,21 @@ # RubyFPV Android Viewer — Roadmap +## Recordings — DJI-style transfer & preview (primary) + +Get onboard HEVC recordings off the drone over its Wi-Fi, review and share them. + +- [x] Material 3 dark "album" UI — thumbnails, duration, size, date +- [x] SSH connection to the drone over dropbear (exec + `cat`, no SFTP subsystem) +- [x] List `/mnt/mmcblk0p1/ruby/*.ts` (+ paired `.osd`) +- [x] Download with progress +- [x] Lossless `.ts` → `.mp4` remux on-device (MediaExtractor + MediaMuxer) +- [x] Native HEVC playback (VideoView) with seeking +- [x] First-frame thumbnails (MediaMetadataRetriever) +- [x] Share sheet (`video/mp4` via FileProvider) +- [x] Delete from drone / phone +- [ ] In-app Wi-Fi join wired to drone AP (pending drone "phone-transfer mode") +- [ ] End-to-end test against the AP, then package as a Ruby-update zip + ## V1 — Basic Video Viewer (current) Core functionality: plug in phone via USB-C, see what the pilot sees. diff --git a/app/build.gradle b/app/build.gradle index 355a599..6134f3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,15 +10,15 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "1.1" } buildTypes { release { minifyEnabled = true shrinkResources = true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } @@ -27,3 +27,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } } + +dependencies { + implementation libs.material + implementation libs.constraintlayout + implementation libs.swiperefreshlayout + implementation libs.jsch +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f7ffdb5 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# JSch (mwiede fork) loads ciphers/kex/mac/hostkey classes reflectively by name. +-keep class com.jcraft.jsch.** { *; } +-keep class com.jcraft.jzlib.** { *; } +-dontwarn com.jcraft.jsch.** +-dontwarn org.ietf.jgss.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 785a2eb..867481b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,21 +1,53 @@ + + + + + + + + android:label="@string/app_name"> + + + + + + + + + + diff --git a/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java b/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java new file mode 100644 index 0000000..b9ba25e --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java @@ -0,0 +1,96 @@ +package com.rubyfpv.viewer.player; + +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.MediaController; +import android.widget.Toast; +import android.widget.VideoView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.FileProvider; + +import com.google.android.material.appbar.MaterialToolbar; +import com.rubyfpv.viewer.R; + +import java.io.File; + +/** + * Plays a downloaded/remuxed clip with the platform {@link VideoView} (hardware + * HEVC decode + reliable seeking on the MP4). Share action re-uses the same file. + */ +public class PlaybackActivity extends AppCompatActivity { + + public static final String EXTRA_PATH = "path"; + public static final String EXTRA_TITLE = "title"; + + private VideoView video; + private File file; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_playback); + + String path = getIntent().getStringExtra(EXTRA_PATH); + String title = getIntent().getStringExtra(EXTRA_TITLE); + if (path == null) { finish(); return; } + file = new File(path); + + MaterialToolbar toolbar = findViewById(R.id.player_toolbar); + toolbar.setTitle(title != null ? title : file.getName()); + toolbar.setNavigationOnClickListener(v -> finish()); + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.menu_share) { share(); return true; } + return false; + }); + + video = findViewById(R.id.video); + MediaController controller = new MediaController(this); + controller.setAnchorView(video); + video.setMediaController(controller); + video.setVideoURI(Uri.fromFile(file)); + video.setOnPreparedListener(mp -> { + mp.setLooping(false); + video.start(); + }); + video.setOnErrorListener((mp, what, extra) -> { + Toast.makeText(this, R.string.toast_no_player, Toast.LENGTH_LONG).show(); + return true; + }); + + // Tap toggles the system bars / toolbar for an immersive look. + findViewById(R.id.player_root).setOnClickListener(v -> { + boolean shown = toolbar.getVisibility() == View.VISIBLE; + toolbar.setVisibility(shown ? View.GONE : View.VISIBLE); + }); + } + + private void share() { + try { + Uri uri = FileProvider.getUriForFile( + this, getPackageName() + ".fileprovider", file); + android.content.Intent send = new android.content.Intent(android.content.Intent.ACTION_SEND); + send.setType("video/mp4"); + send.putExtra(android.content.Intent.EXTRA_STREAM, uri); + send.addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(android.content.Intent.createChooser(send, getString(R.string.share_title))); + } catch (Exception e) { + Toast.makeText(this, R.string.toast_share_failed, Toast.LENGTH_SHORT).show(); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (video != null && video.isPlaying()) video.pause(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (video != null) video.stopPlayback(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Format.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Format.java new file mode 100644 index 0000000..318cb7d --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Format.java @@ -0,0 +1,38 @@ +package com.rubyfpv.viewer.recordings; + +import android.text.format.DateUtils; + +import java.util.Locale; + +/** Small formatting helpers for sizes, durations and dates. */ +public final class Format { + + private Format() {} + + public static String size(long bytes) { + if (bytes <= 0) return "—"; + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + if (kb < 1024) return String.format(Locale.US, "%.0f KB", kb); + double mb = kb / 1024.0; + if (mb < 1024) return String.format(Locale.US, "%.1f MB", mb); + return String.format(Locale.US, "%.2f GB", mb / 1024.0); + } + + public static String clock(long ms) { + if (ms <= 0) return ""; + long totalSec = ms / 1000; + long h = totalSec / 3600; + long m = (totalSec % 3600) / 60; + long s = totalSec % 60; + if (h > 0) return String.format(Locale.US, "%d:%02d:%02d", h, m, s); + return String.format(Locale.US, "%d:%02d", m, s); + } + + public static String date(long epochSec) { + if (epochSec <= 0) return ""; + long now = System.currentTimeMillis(); + return DateUtils.getRelativeTimeSpanString( + epochSec * 1000L, now, DateUtils.MINUTE_IN_MILLIS).toString(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java new file mode 100644 index 0000000..0ceb77e --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java @@ -0,0 +1,40 @@ +package com.rubyfpv.viewer.recordings; + +import java.io.File; + +/** + * One onboard clip. Keyed by {@link #stem} (filename without extension) so a + * drone-side {@code .ts} and a locally-remuxed {@code .mp4} describe the same item. + */ +public class Recording { + + public enum State { + ON_DRONE, // exists on the drone, not yet downloaded + DOWNLOADING, // SFTP/exec pull in progress + REMUXING, // .ts -> .mp4 rewrap in progress + READY, // local playable file present + FAILED // download or remux error + } + + public final String stem; // e.g. rec_00h03m08s_4c3c + public String tsName; // e.g. rec_00h03m08s_4c3c.ts (null if drone copy gone) + public String osdName; // paired .osd on drone, or null + + public long remoteSize; // bytes on drone (0 if unknown / local-only) + public long mtimeEpoch; // seconds; drone mtime or local lastModified + + public State state = State.ON_DRONE; + public int progress; // 0..100 during download + public boolean onDrone; // still present on the SD card + + public File localFile; // playable file (.mp4 preferred, else .ts) + public long durationMs = -1; // from MediaMetadataRetriever once READY + + public Recording(String stem) { + this.stem = stem; + } + + public boolean isLocal() { + return localFile != null && localFile.exists(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java new file mode 100644 index 0000000..95a4225 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -0,0 +1,497 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.textfield.TextInputEditText; +import com.rubyfpv.viewer.MainActivity; +import com.rubyfpv.viewer.R; +import com.rubyfpv.viewer.player.PlaybackActivity; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Primary screen: browse, download, preview and share onboard drone recordings. + * + *

All drone I/O runs on {@link #io} (a single serial thread so the dropbear + * session is never used concurrently); CPU work (remux, thumbnail) runs on + * {@link #work}. UI updates are posted back to {@link #ui}.

+ */ +public class RecordingsActivity extends AppCompatActivity + implements RecordingsAdapter.Listener { + + private static final String PREFS = "ruby_conn"; + + private final Handler ui = new Handler(Looper.getMainLooper()); + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private final ExecutorService work = Executors.newSingleThreadExecutor(); + + private final Map byStem = new LinkedHashMap<>(); + private RecordingsAdapter adapter; + + private View statusDot; + private TextView statusText; + private MaterialButton btnConnect; + private SwipeRefreshLayout swipe; + private View emptyView; + private TextView emptyTitle, emptyBody; + private View loading; + + private RubyShell shell; + private WifiJoiner wifi; + private volatile boolean connected; + private File dir; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_recordings); + + File base = getExternalFilesDir(null); + if (base == null) base = getFilesDir(); // external storage unavailable + dir = new File(base, "recordings"); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + wifi = new WifiJoiner(this); + + MaterialToolbar toolbar = findViewById(R.id.toolbar); + toolbar.setOnMenuItemClickListener(this::onMenu); + + statusDot = findViewById(R.id.status_dot); + statusText = findViewById(R.id.status_text); + btnConnect = findViewById(R.id.btn_connect); + swipe = findViewById(R.id.swipe); + emptyView = findViewById(R.id.empty_view); + emptyTitle = findViewById(R.id.empty_title); + emptyBody = findViewById(R.id.empty_body); + loading = findViewById(R.id.loading); + + adapter = new RecordingsAdapter(this); + RecyclerView list = findViewById(R.id.list); + list.setLayoutManager(new LinearLayoutManager(this)); + list.setAdapter(adapter); + + swipe.setColorSchemeColors(ContextCompat.getColor(this, R.color.ruby)); + swipe.setProgressBackgroundColorSchemeColor(ContextCompat.getColor(this, R.color.surface)); + swipe.setOnRefreshListener(() -> { + if (connected) refresh(); + else { swipe.setRefreshing(false); connect(); } + }); + + btnConnect.setOnClickListener(v -> { + if (connected) disconnect(); + else connect(); + }); + + loadLocal(); + setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); + } + + // ── Menu ──────────────────────────────────────────────────────────── + + private boolean onMenu(android.view.MenuItem item) { + int id = item.getItemId(); + if (id == R.id.menu_refresh) { + if (connected) refresh(); else connect(); + return true; + } else if (id == R.id.menu_live) { + startActivity(new Intent(this, MainActivity.class)); + return true; + } else if (id == R.id.menu_connection) { + showConnectionDialog(); + return true; + } + return false; + } + + // ── Connection ────────────────────────────────────────────────────── + + private void connect() { + if (connected) return; + setStatus(StatusKind.BUSY, getString(R.string.status_connecting)); + btnConnect.setEnabled(false); + RubyShell.Config cfg = readConfig(); + io.execute(() -> { + RubyShell s = new RubyShell(cfg); + try { + s.connect(); + List remote = s.listRecordings(); + ui.post(() -> { + shell = s; + connected = true; + btnConnect.setEnabled(true); + btnConnect.setText(R.string.disconnect); + btnConnect.setIconResource(R.drawable.ic_check_circle); + setStatus(StatusKind.ONLINE, getString(R.string.status_connected, cfg.host)); + mergeRemote(remote); + }); + } catch (Exception e) { + s.close(); + ui.post(() -> { + btnConnect.setEnabled(true); + setStatus(StatusKind.OFFLINE, getString(R.string.status_error)); + Snackbar.make(swipe, getString(R.string.status_error) + ": " + e.getMessage(), + Snackbar.LENGTH_LONG) + .setAction(R.string.join_wifi, v -> WifiJoiner.openWifiPanel(this)) + .show(); + refreshUi(); + }); + } + }); + } + + private void disconnect() { + connected = false; + btnConnect.setText(R.string.connect); + btnConnect.setIconResource(R.drawable.ic_wifi); + setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); + io.execute(() -> { + if (shell != null) { shell.close(); shell = null; } + }); + wifi.release(); + // Drop drone-only items, keep local ones. + ui.post(() -> { + List drop = new ArrayList<>(); + for (Recording r : byStem.values()) { + r.onDrone = false; + if (!r.isLocal()) drop.add(r.stem); + } + for (String k : drop) byStem.remove(k); + refreshUi(); + }); + } + + private void refresh() { + if (!connected || shell == null) return; + swipe.setRefreshing(true); + io.execute(() -> { + try { + List remote = shell.listRecordings(); + ui.post(() -> { mergeRemote(remote); swipe.setRefreshing(false); }); + } catch (Exception e) { + ui.post(() -> { + swipe.setRefreshing(false); + Toast.makeText(this, "Refresh failed: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + /** Merge a fresh drone listing into the model, preserving local state. */ + private void mergeRemote(List remote) { + for (Recording r : byStem.values()) r.onDrone = false; + for (Recording rr : remote) { + Recording cur = byStem.get(rr.stem); + if (cur == null) { + byStem.put(rr.stem, rr); + } else { + cur.tsName = rr.tsName; + cur.osdName = rr.osdName; + cur.remoteSize = rr.remoteSize; + if (cur.mtimeEpoch <= 0) cur.mtimeEpoch = rr.mtimeEpoch; + cur.onDrone = true; + } + } + // purge entries that are neither on the drone nor downloaded + List drop = new ArrayList<>(); + for (Recording r : byStem.values()) { + if (!r.onDrone && !r.isLocal()) drop.add(r.stem); + } + for (String k : drop) byStem.remove(k); + refreshUi(); + } + + // ── Local scan ────────────────────────────────────────────────────── + + private void loadLocal() { + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + String name = f.getName(); + boolean mp4 = name.endsWith(".mp4"); + boolean ts = name.endsWith(".ts"); + if (!mp4 && !ts) continue; + String stem = name.substring(0, name.lastIndexOf('.')); + Recording r = byStem.get(stem); + if (r == null) { r = new Recording(stem); byStem.put(stem, r); } + // prefer mp4 as the playable file + if (mp4 || r.localFile == null) r.localFile = f; + r.state = Recording.State.READY; + r.mtimeEpoch = f.lastModified() / 1000L; + } + // lazily extract thumbnails/durations for ready clips + for (Recording r : byStem.values()) { + if (r.state == Recording.State.READY && r.localFile != null) extractMeta(r); + } + refreshUi(); + } + + private void extractMeta(Recording r) { + if (Thumbs.CACHE.get(r.stem) != null && r.durationMs > 0) return; + final File f = r.localFile; + work.execute(() -> { + Thumbs.Meta m = Thumbs.extract(f, r.stem); + ui.post(() -> { + if (m.durationMs > 0) r.durationMs = m.durationMs; + adapter.update(r); + }); + }); + } + + // ── Adapter callbacks ─────────────────────────────────────────────── + + @Override + public void onPrimary(Recording r) { + // Download (or retry) + if (!connected || shell == null) { + Snackbar.make(swipe, R.string.empty_disconnected_title, Snackbar.LENGTH_SHORT) + .setAction(R.string.connect, v -> connect()).show(); + return; + } + if (r.tsName == null) return; + r.state = Recording.State.DOWNLOADING; + r.progress = 0; + adapter.update(r); + + io.execute(() -> { + File ts = new File(dir, r.tsName); + try { + shell.download(r.tsName, r.remoteSize, ts, (t, total) -> { + int pct = total > 0 ? (int) (t * 100 / total) : 0; + if (pct != r.progress) { + r.progress = pct; + ui.post(() -> adapter.update(r)); + } + }); + if (r.osdName != null) { + try { + shell.download(r.osdName, 0, new File(dir, r.osdName), null); + } catch (Exception ignore) { /* OSD sidecar is optional */ } + } + ui.post(() -> { r.state = Recording.State.REMUXING; adapter.update(r); }); + remux(r, ts); + } catch (Exception e) { + //noinspection ResultOfMethodCallIgnored + ts.delete(); + ui.post(() -> { + r.state = Recording.State.FAILED; + adapter.update(r); + Toast.makeText(this, "Download failed: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + private void remux(Recording r, File ts) { + work.execute(() -> { + File mp4 = new File(dir, r.stem + ".mp4"); + boolean ok = Remuxer.remux(ts, mp4); + File playable; + if (ok) { + //noinspection ResultOfMethodCallIgnored + ts.delete(); + playable = mp4; + } else { + playable = ts; // fall back to playing the .ts directly + } + Thumbs.Meta m = Thumbs.extract(playable, r.stem); + ui.post(() -> { + r.localFile = playable; + r.durationMs = m.durationMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + }); + } + + @Override + public void onPlay(Recording r) { + if (r.localFile == null || !r.localFile.exists()) return; + Intent i = new Intent(this, PlaybackActivity.class); + i.putExtra(PlaybackActivity.EXTRA_PATH, r.localFile.getAbsolutePath()); + i.putExtra(PlaybackActivity.EXTRA_TITLE, r.stem); + startActivity(i); + } + + @Override + public void onShare(Recording r) { + if (r.localFile == null || !r.localFile.exists()) return; + try { + android.net.Uri uri = FileProvider.getUriForFile( + this, getPackageName() + ".fileprovider", r.localFile); + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("video/mp4"); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(send, getString(R.string.share_title))); + } catch (Exception e) { + Toast.makeText(this, R.string.toast_share_failed, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onDelete(Recording r) { + boolean drone = r.onDrone && connected; + String msg = drone + ? getString(R.string.delete_drone_body, r.stem) + : "Remove the downloaded copy of \"" + r.stem + "\"?"; + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_title) + .setMessage(msg) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_confirm, (d, w) -> doDelete(r, drone)) + .show(); + } + + private void doDelete(Recording r, boolean drone) { + // local files first + deleteLocal(r); + if (drone && shell != null) { + io.execute(() -> { + try { shell.delete(r.tsName, r.osdName); } catch (Exception ignored) {} + }); + } + Thumbs.CACHE.remove(r.stem); + byStem.remove(r.stem); + refreshUi(); + } + + private void deleteLocal(Recording r) { + File[] victims = { + r.localFile, + new File(dir, r.stem + ".mp4"), + new File(dir, r.stem + ".ts"), + new File(dir, r.stem + ".osd") + }; + for (File f : victims) { + if (f != null && f.exists()) //noinspection ResultOfMethodCallIgnored + f.delete(); + } + r.localFile = null; + } + + // ── UI helpers ────────────────────────────────────────────────────── + + private void refreshUi() { + List sorted = new ArrayList<>(byStem.values()); + Collections.sort(sorted, (a, b) -> Long.compare(b.mtimeEpoch, a.mtimeEpoch)); + adapter.submit(sorted); + loading.setVisibility(View.GONE); + + if (sorted.isEmpty()) { + emptyView.setVisibility(View.VISIBLE); + if (connected) { + emptyTitle.setText(R.string.empty_none_title); + emptyBody.setText(R.string.empty_none_body); + } else { + emptyTitle.setText(R.string.empty_disconnected_title); + emptyBody.setText(R.string.empty_disconnected_body); + } + } else { + emptyView.setVisibility(View.GONE); + } + } + + private enum StatusKind { OFFLINE, BUSY, ONLINE } + + private void setStatus(StatusKind kind, String text) { + int colorRes; + switch (kind) { + case ONLINE: colorRes = R.color.status_online; break; + case BUSY: colorRes = R.color.status_busy; break; + default: colorRes = R.color.status_offline; break; + } + statusDot.setBackgroundTintList( + ColorStateList.valueOf(ContextCompat.getColor(this, colorRes))); + statusText.setText(text); + } + + // ── Connection settings ───────────────────────────────────────────── + + private RubyShell.Config readConfig() { + SharedPreferences p = getSharedPreferences(PREFS, Context.MODE_PRIVATE); + RubyShell.Config c = new RubyShell.Config(); + c.host = p.getString("host", "192.168.4.1"); + c.port = p.getInt("port", 22); + c.user = p.getString("user", "root"); + c.pass = p.getString("pass", "12345"); + return c; + } + + private void showConnectionDialog() { + SharedPreferences p = getSharedPreferences(PREFS, Context.MODE_PRIVATE); + View v = getLayoutInflater().inflate(R.layout.dialog_connection, null); + TextInputEditText host = v.findViewById(R.id.in_host); + TextInputEditText user = v.findViewById(R.id.in_user); + TextInputEditText pass = v.findViewById(R.id.in_pass); + TextInputEditText ssid = v.findViewById(R.id.in_ssid); + TextInputEditText psk = v.findViewById(R.id.in_psk); + host.setText(p.getString("host", "192.168.4.1")); + user.setText(p.getString("user", "root")); + pass.setText(p.getString("pass", "12345")); + ssid.setText(p.getString("ssid", "")); + psk.setText(p.getString("psk", "")); + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.settings_title) + .setView(v) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.settings_save, (d, w) -> { + p.edit() + .putString("host", text(host, "192.168.4.1")) + .putString("user", text(user, "root")) + .putString("pass", text(pass, "12345")) + .putString("ssid", text(ssid, "")) + .putString("psk", text(psk, "")) + .apply(); + Toast.makeText(this, R.string.settings_save, Toast.LENGTH_SHORT).show(); + }) + .show(); + } + + private static String text(TextInputEditText e, String def) { + CharSequence c = e.getText(); + String s = c == null ? "" : c.toString().trim(); + return s.isEmpty() ? def : s; + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + @Override + protected void onDestroy() { + super.onDestroy(); + io.execute(() -> { if (shell != null) shell.close(); }); + wifi.release(); + io.shutdown(); + work.shutdown(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java new file mode 100644 index 0000000..4a94415 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java @@ -0,0 +1,176 @@ +package com.rubyfpv.viewer.recordings; + +import android.graphics.Bitmap; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.rubyfpv.viewer.R; + +import java.util.ArrayList; +import java.util.List; + +public class RecordingsAdapter extends RecyclerView.Adapter { + + public interface Listener { + void onPrimary(Recording r); // download / retry / play + void onPlay(Recording r); + void onShare(Recording r); + void onDelete(Recording r); + } + + private final List items = new ArrayList<>(); + private final Listener listener; + + public RecordingsAdapter(Listener listener) { + this.listener = listener; + setHasStableIds(true); + } + + public void submit(List next) { + items.clear(); + items.addAll(next); + notifyDataSetChanged(); + } + + public void update(Recording r) { + int i = items.indexOf(r); + if (i >= 0) notifyItemChanged(i); + } + + @Override + public long getItemId(int position) { + return items.get(position).stem.hashCode(); + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_recording, parent, false); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH h, int position) { + Recording r = items.get(position); + h.title.setText(r.stem); + + // Thumbnail + Bitmap bmp = Thumbs.CACHE.get(r.stem); + if (bmp != null) { + h.thumb.setImageBitmap(bmp); + } else { + h.thumb.setImageDrawable(null); // reveal placeholder background + } + + // Duration badge + if (r.durationMs > 0) { + h.badge.setText(Format.clock(r.durationMs)); + h.badge.setVisibility(View.VISIBLE); + } else { + h.badge.setVisibility(View.GONE); + } + + boolean ready = r.state == Recording.State.READY; + h.playOverlay.setVisibility(ready ? View.VISIBLE : View.GONE); + + long shownSize = r.remoteSize > 0 ? r.remoteSize + : (r.localFile != null ? r.localFile.length() : 0); + String when = Format.date(r.mtimeEpoch); + + switch (r.state) { + case DOWNLOADING: + h.progress.setVisibility(View.VISIBLE); + h.progress.setIndeterminate(false); + h.progress.setProgress(r.progress); + h.meta.setText(h.ctx(R.string.state_downloading, r.progress)); + h.primary.setVisibility(View.GONE); + break; + case REMUXING: + h.progress.setVisibility(View.VISIBLE); + h.progress.setIndeterminate(true); + h.meta.setText(R.string.state_remuxing); + h.primary.setVisibility(View.GONE); + break; + case READY: + h.progress.setVisibility(View.GONE); + h.meta.setText(join(Format.size(shownSize), when)); + h.primary.setVisibility(View.VISIBLE); + h.primary.setText(R.string.action_play); + h.primary.setIconResource(R.drawable.ic_play_arrow); + break; + case FAILED: + h.progress.setVisibility(View.GONE); + h.meta.setText(R.string.state_failed); + h.primary.setVisibility(View.VISIBLE); + h.primary.setText(R.string.action_download); + h.primary.setIconResource(R.drawable.ic_download); + break; + case ON_DRONE: + default: + h.progress.setVisibility(View.GONE); + h.meta.setText(join(Format.size(shownSize), when)); + h.primary.setVisibility(View.VISIBLE); + h.primary.setText(R.string.action_download); + h.primary.setIconResource(R.drawable.ic_download); + break; + } + + h.share.setEnabled(ready); + h.delete.setEnabled(r.onDrone || r.isLocal()); + + h.primary.setOnClickListener(v -> { + if (r.state == Recording.State.READY) listener.onPlay(r); + else listener.onPrimary(r); + }); + View.OnClickListener playClick = v -> { + if (r.state == Recording.State.READY) listener.onPlay(r); + }; + h.playOverlay.setOnClickListener(playClick); + h.thumb.setOnClickListener(playClick); + h.share.setOnClickListener(v -> listener.onShare(r)); + h.delete.setOnClickListener(v -> listener.onDelete(r)); + } + + private static String join(String a, String b) { + if (b == null || b.isEmpty()) return a; + return a + " · " + b; + } + + @Override + public int getItemCount() { + return items.size(); + } + + static class VH extends RecyclerView.ViewHolder { + final ImageView thumb, playOverlay; + final TextView badge, title, meta; + final LinearProgressIndicator progress; + final MaterialButton primary, share, delete; + + VH(@NonNull View v) { + super(v); + thumb = v.findViewById(R.id.thumb); + playOverlay = v.findViewById(R.id.play_overlay); + badge = v.findViewById(R.id.badge_duration); + title = v.findViewById(R.id.title); + meta = v.findViewById(R.id.meta); + progress = v.findViewById(R.id.progress); + primary = v.findViewById(R.id.btn_primary); + share = v.findViewById(R.id.btn_share); + delete = v.findViewById(R.id.btn_delete); + } + + String ctx(int resId, Object... args) { + return itemView.getContext().getString(resId, args); + } + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java new file mode 100644 index 0000000..d2db8a1 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java @@ -0,0 +1,105 @@ +package com.rubyfpv.viewer.recordings; + +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.media.MediaMuxer; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Lossless rewrap of an MPEG-TS elementary stream into an MP4 container. + * + *

Uses the in-platform {@link MediaExtractor} + {@link MediaMuxer} (API 24+ + * muxes HEVC) to copy samples verbatim — zero re-encode, bit-identical video. + * MP4 gives ExoPlayer/VideoView a real seek index (raw .ts has none) and a + * share-friendly {@code video/mp4} container.

+ */ +public final class Remuxer { + + private static final String TAG = "Remuxer"; + + private Remuxer() {} + + /** @return true if the output MP4 was written successfully. */ + public static boolean remux(File src, File dst) { + MediaExtractor extractor = new MediaExtractor(); + MediaMuxer muxer = null; + boolean ok = false; + try { + extractor.setDataSource(src.getAbsolutePath()); + int trackCount = extractor.getTrackCount(); + int[] muxIndex = new int[trackCount]; + int maxInput = 1 << 20; // 1 MB floor + + muxer = new MediaMuxer(dst.getAbsolutePath(), + MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + + boolean haveVideo = false; + for (int i = 0; i < trackCount; i++) { + muxIndex[i] = -1; + MediaFormat fmt = extractor.getTrackFormat(i); + String mime = fmt.getString(MediaFormat.KEY_MIME); + if (mime == null) continue; + if (mime.startsWith("video/") || mime.startsWith("audio/")) { + extractor.selectTrack(i); + muxIndex[i] = muxer.addTrack(fmt); + if (fmt.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) { + maxInput = Math.max(maxInput, + fmt.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)); + } + if (mime.startsWith("video/")) haveVideo = true; + } + } + if (!haveVideo) { + Log.w(TAG, "No video track in " + src.getName()); + return false; + } + + ByteBuffer buf = ByteBuffer.allocate(maxInput); + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + muxer.start(); + + long lastPts = 0; + while (true) { + int track = extractor.getSampleTrackIndex(); + if (track < 0) break; + int size = extractor.readSampleData(buf, 0); + if (size < 0) break; + + long pts = extractor.getSampleTime(); + if (pts < 0) pts = lastPts; + else lastPts = pts; + + info.offset = 0; + info.size = size; + info.presentationTimeUs = pts; + info.flags = (extractor.getSampleFlags() + & MediaExtractor.SAMPLE_FLAG_SYNC) != 0 + ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; + + int dest = muxIndex[track]; + if (dest >= 0) muxer.writeSampleData(dest, buf, info); + extractor.advance(); + } + ok = true; + } catch (IOException | IllegalArgumentException | IllegalStateException e) { + Log.e(TAG, "Remux failed: " + e.getMessage()); + ok = false; + } finally { + try { extractor.release(); } catch (Exception ignored) {} + if (muxer != null) { + try { muxer.stop(); } catch (Exception ignored) {} + try { muxer.release(); } catch (Exception ignored) {} + } + } + if (!ok) { + //noinspection ResultOfMethodCallIgnored + dst.delete(); + } + return ok; + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java new file mode 100644 index 0000000..f21ecbc --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java @@ -0,0 +1,189 @@ +package com.rubyfpv.viewer.recordings; + +import android.util.Log; + +import com.jcraft.jsch.Channel; +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * SSH client for the drone, talking to its dropbear server. + * + *

The drone's dropbear has no SFTP / SCP subsystem (pscp / pscp -sftp + * both fail against the SigmaStar build), so this class never opens an SFTP channel. + * Instead it runs plain commands over {@code exec} channels — listing with + * {@code stat}, transferring by streaming {@code cat} — which is the same approach + * that works from the desktop via {@code plink + stdin}.

+ * + *

Not thread-safe: drive all calls from a single serial executor.

+ */ +public class RubyShell { + + private static final String TAG = "RubyShell"; + public static final String REMOTE_DIR = "/mnt/mmcblk0p1/ruby"; + + public static class Config { + public String host = "192.168.4.1"; + public int port = 22; + public String user = "root"; + public String pass = "12345"; + } + + public interface Progress { + void onBytes(long transferred, long total); + } + + private final Config cfg; + private Session session; + + public RubyShell(Config cfg) { + this.cfg = cfg; + } + + public void connect() throws Exception { + JSch jsch = new JSch(); + Session s = jsch.getSession(cfg.user, cfg.host, cfg.port); + s.setPassword(cfg.pass); + Properties p = new Properties(); + p.put("StrictHostKeyChecking", "no"); // point-to-point AP, no host DB + s.setConfig(p); + s.setConfig("PreferredAuthentications", "password,keyboard-interactive"); + s.setServerAliveInterval(15000); + s.connect(8000); + session = s; + Log.i(TAG, "Connected to " + cfg.user + "@" + cfg.host + ":" + cfg.port); + } + + public boolean isConnected() { + return session != null && session.isConnected(); + } + + public void close() { + if (session != null) { + session.disconnect(); + session = null; + } + } + + /** List {@code *.ts} (+ paired {@code *.osd}) in the recordings dir. */ + public List listRecordings() throws Exception { + // for-loop + stat keeps output trivially parseable: name|size|mtime + String cmd = "cd " + REMOTE_DIR + " 2>/dev/null && " + + "for f in *.ts *.osd; do [ -e \"$f\" ] && " + + "stat -c '%n|%s|%Y' \"$f\"; done"; + String out = exec(cmd); + + java.util.LinkedHashMap byStem = new java.util.LinkedHashMap<>(); + List osd = new ArrayList<>(); + + for (String line : out.split("\n")) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("\\|"); + if (parts.length < 3) continue; + String name = parts[0]; + long sz; + long mt; + try { + sz = Long.parseLong(parts[1].trim()); + mt = Long.parseLong(parts[2].trim()); + } catch (NumberFormatException e) { + continue; + } + if (name.endsWith(".ts")) { + String stem = name.substring(0, name.length() - 3); + Recording r = new Recording(stem); + r.tsName = name; + r.remoteSize = sz; + r.mtimeEpoch = mt; + r.onDrone = true; + byStem.put(stem, r); + } else if (name.endsWith(".osd")) { + osd.add(new String[]{name.substring(0, name.length() - 4), name}); + } + } + for (String[] o : osd) { + Recording r = byStem.get(o[0]); + if (r != null) r.osdName = o[1]; + } + return new ArrayList<>(byStem.values()); + } + + /** Stream a remote file to {@code dest} via {@code cat}. Returns bytes written. */ + public long download(String remoteName, long expectedSize, File dest, Progress cb) + throws Exception { + String cmd = "cat '" + REMOTE_DIR + "/" + remoteName + "'"; + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + ch.setCommand(cmd); + ch.setInputStream(null); + InputStream in = ch.getInputStream(); + long total = 0; + try (OutputStream fout = new FileOutputStream(dest)) { + ch.connect(); + byte[] buf = new byte[64 * 1024]; + int n; + long lastCb = 0; + while ((n = in.read(buf)) != -1) { + fout.write(buf, 0, n); + total += n; + if (cb != null && (total - lastCb) > 256 * 1024) { + lastCb = total; + cb.onBytes(total, expectedSize); + } + } + fout.flush(); + } finally { + disconnect(ch); + } + if (cb != null) cb.onBytes(total, expectedSize); + int code = ch.getExitStatus(); + if (code != 0 && code != -1) { + throw new IOException("Remote cat exited " + code + " for " + remoteName); + } + return total; + } + + /** Delete one or more files from the recordings dir. */ + public void delete(String... names) throws Exception { + StringBuilder sb = new StringBuilder("rm -f"); + for (String n : names) { + if (n == null) continue; + sb.append(" '").append(REMOTE_DIR).append("/").append(n).append("'"); + } + exec(sb.toString()); + } + + // ── internals ─────────────────────────────────────────────────────── + + private String exec(String cmd) throws Exception { + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + ch.setCommand(cmd); + ch.setInputStream(null); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InputStream in = ch.getInputStream(); + try { + ch.connect(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + } finally { + disconnect(ch); + } + return out.toString("UTF-8"); + } + + private static void disconnect(Channel ch) { + try { ch.disconnect(); } catch (Exception ignored) {} + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java new file mode 100644 index 0000000..a442aeb --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -0,0 +1,63 @@ +package com.rubyfpv.viewer.recordings; + +import android.graphics.Bitmap; +import android.media.MediaMetadataRetriever; +import android.util.LruCache; + +import java.io.File; + +/** First-frame thumbnails (DJI-style) + duration, via MediaMetadataRetriever. */ +public final class Thumbs { + + private static final int MAX_W = 640; + + public static final LruCache CACHE; + + static { + int kb = (int) (Runtime.getRuntime().maxMemory() / 1024); + CACHE = new LruCache(kb / 8) { + @Override + protected int sizeOf(String key, Bitmap value) { + return value.getByteCount() / 1024; + } + }; + } + + private Thumbs() {} + + public static class Meta { + public Bitmap bitmap; + public long durationMs = -1; + } + + public static Meta extract(File file, String stem) { + Meta m = new Meta(); + MediaMetadataRetriever r = new MediaMetadataRetriever(); + try { + r.setDataSource(file.getAbsolutePath()); + String dur = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + if (dur != null) { + try { m.durationMs = Long.parseLong(dur); } catch (NumberFormatException ignored) {} + } + Bitmap frame = r.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC); + if (frame != null) { + m.bitmap = scale(frame); + if (m.bitmap != frame) frame.recycle(); + if (stem != null && m.bitmap != null) CACHE.put(stem, m.bitmap); + } + } catch (Exception ignored) { + // unreadable clip — leave bitmap null, caller shows placeholder + } finally { + try { r.release(); } catch (Exception ignored) {} + } + return m; + } + + private static Bitmap scale(Bitmap src) { + int w = src.getWidth(); + int h = src.getHeight(); + if (w <= MAX_W || w == 0) return src; + float ratio = (float) MAX_W / w; + return Bitmap.createScaledBitmap(src, MAX_W, Math.round(h * ratio), true); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java b/app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java new file mode 100644 index 0000000..43fe83d --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/WifiJoiner.java @@ -0,0 +1,91 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.wifi.WifiNetworkSpecifier; +import android.os.Build; +import android.provider.Settings; +import android.util.Log; + +/** + * Joins the drone's Wi-Fi AP ("phone-transfer mode"). On API 29+ this uses a + * {@link WifiNetworkSpecifier} peer-to-peer request and binds the process to the + * resulting network so SSH traffic is routed to the drone (not cellular). On older + * devices, or when no SSID is configured yet, callers fall back to {@link #openWifiPanel}. + */ +public class WifiJoiner { + + private static final String TAG = "WifiJoiner"; + + public interface Callback { + void onBound(); + void onFailed(String reason); + } + + private final Context appCtx; + private final ConnectivityManager cm; + private ConnectivityManager.NetworkCallback callback; + + public WifiJoiner(Context ctx) { + this.appCtx = ctx.getApplicationContext(); + this.cm = (ConnectivityManager) appCtx.getSystemService(Context.CONNECTIVITY_SERVICE); + } + + public boolean isSupported() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + /** Request and bind to the drone AP. Holds the binding until {@link #release()}. */ + public void join(String ssid, String psk, Callback cb) { + if (!isSupported()) { + cb.onFailed("Android version too old for in-app join"); + return; + } + release(); + WifiNetworkSpecifier.Builder b = new WifiNetworkSpecifier.Builder().setSsid(ssid); + if (psk != null && !psk.isEmpty()) b.setWpa2Passphrase(psk); + + NetworkRequest req = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(b.build()) + .build(); + + callback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + cm.bindProcessToNetwork(network); + Log.i(TAG, "Bound to drone AP " + ssid); + cb.onBound(); + } + + @Override + public void onUnavailable() { + Log.w(TAG, "AP " + ssid + " unavailable"); + cb.onFailed("Drone Wi-Fi not found"); + } + }; + cm.requestNetwork(req, callback); + } + + public void release() { + if (callback != null) { + try { cm.unregisterNetworkCallback(callback); } catch (Exception ignored) {} + callback = null; + } + try { cm.bindProcessToNetwork(null); } catch (Exception ignored) {} + } + + /** Open the system Wi-Fi picker so the user can join the AP manually. */ + public static void openWifiPanel(Context ctx) { + Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? new Intent(Settings.Panel.ACTION_WIFI) + : new Intent(Settings.ACTION_WIFI_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(intent); + } +} diff --git a/app/src/main/res/drawable/badge_bg.xml b/app/src/main/res/drawable/badge_bg.xml new file mode 100644 index 0000000..7592bd8 --- /dev/null +++ b/app/src/main/res/drawable/badge_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..f2e883a --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..950a804 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..2237161 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..317fcb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_live_tv.xml b/app/src/main/res/drawable/ic_live_tv.xml new file mode 100644 index 0000000..5d899a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_live_tv.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..cb6f8d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000..1d0090e --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..d1810a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..78e9cb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..cf08638 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_videocam.xml b/app/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 0000000..68e6e4a --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi.xml b/app/src/main/res/drawable/ic_wifi.xml new file mode 100644 index 0000000..1c9fa8e --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/play_scrim.xml b/app/src/main/res/drawable/play_scrim.xml new file mode 100644 index 0000000..8bcfa34 --- /dev/null +++ b/app/src/main/res/drawable/play_scrim.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/status_dot.xml b/app/src/main/res/drawable/status_dot.xml new file mode 100644 index 0000000..1308737 --- /dev/null +++ b/app/src/main/res/drawable/status_dot.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/thumb_placeholder.xml b/app/src/main/res/drawable/thumb_placeholder.xml new file mode 100644 index 0000000..43281e3 --- /dev/null +++ b/app/src/main/res/drawable/thumb_placeholder.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml new file mode 100644 index 0000000..d65dfc2 --- /dev/null +++ b/app/src/main/res/layout/activity_playback.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_recordings.xml b/app/src/main/res/layout/activity_recordings.xml new file mode 100644 index 0000000..7e05c7e --- /dev/null +++ b/app/src/main/res/layout/activity_recordings.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_connection.xml b/app/src/main/res/layout/dialog_connection.xml new file mode 100644 index 0000000..e6e7a22 --- /dev/null +++ b/app/src/main/res/layout/dialog_connection.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recording.xml b/app/src/main/res/layout/item_recording.xml new file mode 100644 index 0000000..b84d293 --- /dev/null +++ b/app/src/main/res/layout/item_recording.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/playback.xml b/app/src/main/res/menu/playback.xml new file mode 100644 index 0000000..79425da --- /dev/null +++ b/app/src/main/res/menu/playback.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/menu/recordings.xml b/app/src/main/res/menu/recordings.xml new file mode 100644 index 0000000..50de0f2 --- /dev/null +++ b/app/src/main/res/menu/recordings.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a0d73a2 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,25 @@ + + + + #E0344A + #B5263A + #FFFFFF + + + #0F1113 + #1A1D21 + #23272C + #33383F + + + #F2F4F6 + #9AA3AD + #5A626B + + + #34C759 + #FFB020 + #6B7280 + + #CC000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c581b75..c8ca241 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,50 @@ - RubyFPV Viewer + RubyFPV + + Recordings + Live view (USB) + Connection settings + Refresh + + Connect + Disconnect + Join drone Wi-Fi + + Not connected + Connecting… + Connected to %1$s + Connection failed + + Connect to your drone + Put the drone in phone-transfer mode, join its Wi-Fi, then connect to browse onboard recordings. + No recordings found + Nothing on the drone SD card yet. Record some flights, then refresh. + + Download + Play + Share + Delete + On device + + On drone + Downloading… %1$d%% + Preparing… + Ready to play + Failed + + Connection + Drone address + Username + Password + Save + Cancel + + Delete recording? + Delete \"%1$s\" from the drone SD card? The downloaded copy on this phone is kept. + Remove local copy + Delete + + Share recording + No video player available + Could not share file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 9c8d271..8d758c7 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,7 +1,36 @@ - - + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..20249a0 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0185c20..a9d89e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,15 @@ [versions] agp = "8.13.1" +material = "1.12.0" +constraintlayout = "2.1.4" +swiperefresh = "1.1.0" +jsch = "0.2.23" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } + +[libraries] +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefresh" } +jsch = { group = "com.github.mwiede", name = "jsch", version.ref = "jsch" } From d9409fa3ed2a19d1ecfe2aee6ae55914509dc829 Mon Sep 17 00:00:00 2001 From: wkumik Date: Sat, 13 Jun 2026 15:04:56 +0200 Subject: [PATCH 2/7] Recordings: ExoPlayer playback, edge-to-edge insets, grid/list, thumb preload - Playback now uses ExoPlayer (media3) instead of VideoView/MediaPlayer. Fixes "no video player available": MediaPlayer decodes in the mediaserver process, which can't read app-private files; ExoPlayer reads in-process. Also demuxes raw .ts if the remux fell back. - Edge-to-edge fix: pad the app bar for the status bar and the list for the nav bar via WindowInsets (toolbar no longer under the phone status bar). - Two view modes: large list (as before) + compact 2-col grid, toggled from the toolbar and remembered. Grid items act on tap; long-press = context menu. - Thumbnail preload: fetch a small .ts prefix over SSH (head -c) and extract the first frame for not-yet-downloaded clips; thumbnails persist to disk and reload on next launch. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 2 + .../viewer/player/PlaybackActivity.java | 94 ++++++++------ .../viewer/recordings/RecordingsActivity.java | 112 ++++++++++++++++- .../viewer/recordings/RecordingsAdapter.java | 115 ++++++++++++------ .../rubyfpv/viewer/recordings/RubyShell.java | 26 ++++ .../com/rubyfpv/viewer/recordings/Thumbs.java | 28 +++++ app/src/main/res/drawable/ic_grid.xml | 6 + app/src/main/res/drawable/ic_list.xml | 6 + app/src/main/res/layout/activity_playback.xml | 8 +- .../main/res/layout/activity_recordings.xml | 1 + .../res/layout/item_recording_compact.xml | 104 ++++++++++++++++ app/src/main/res/menu/item_context.xml | 7 ++ app/src/main/res/menu/recordings.xml | 6 + app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 3 + 15 files changed, 436 insertions(+), 83 deletions(-) create mode 100644 app/src/main/res/drawable/ic_grid.xml create mode 100644 app/src/main/res/drawable/ic_list.xml create mode 100644 app/src/main/res/layout/item_recording_compact.xml create mode 100644 app/src/main/res/menu/item_context.xml diff --git a/app/build.gradle b/app/build.gradle index 6134f3d..2b021bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,4 +33,6 @@ dependencies { implementation libs.constraintlayout implementation libs.swiperefreshlayout implementation libs.jsch + implementation libs.media3.exoplayer + implementation libs.media3.ui } diff --git a/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java b/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java index b9ba25e..49a23b6 100644 --- a/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java +++ b/app/src/main/java/com/rubyfpv/viewer/player/PlaybackActivity.java @@ -1,16 +1,19 @@ package com.rubyfpv.viewer.player; -import android.media.MediaPlayer; +import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.view.View; -import android.widget.MediaController; import android.widget.Toast; -import android.widget.VideoView; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.FileProvider; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.PlayerView; import com.google.android.material.appbar.MaterialToolbar; import com.rubyfpv.viewer.R; @@ -18,15 +21,19 @@ import java.io.File; /** - * Plays a downloaded/remuxed clip with the platform {@link VideoView} (hardware - * HEVC decode + reliable seeking on the MP4). Share action re-uses the same file. + * Plays a downloaded/remuxed clip with ExoPlayer (media3). ExoPlayer reads the + * file in-process, so app-private files in {@code Android/data//} play fine — + * unlike {@code MediaPlayer}/{@code VideoView}, whose {@code mediaserver} process + * can't open them ("no video player available"). It also demuxes raw {@code .ts} + * if the remux to MP4 failed. */ public class PlaybackActivity extends AppCompatActivity { public static final String EXTRA_PATH = "path"; public static final String EXTRA_TITLE = "title"; - private VideoView video; + private ExoPlayer player; + private PlayerView playerView; private File file; @Override @@ -47,50 +54,59 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { return false; }); - video = findViewById(R.id.video); - MediaController controller = new MediaController(this); - controller.setAnchorView(video); - video.setMediaController(controller); - video.setVideoURI(Uri.fromFile(file)); - video.setOnPreparedListener(mp -> { - mp.setLooping(false); - video.start(); - }); - video.setOnErrorListener((mp, what, extra) -> { - Toast.makeText(this, R.string.toast_no_player, Toast.LENGTH_LONG).show(); - return true; - }); + playerView = findViewById(R.id.player_view); + // Mirror the controller visibility onto the toolbar for a clean look. + playerView.setControllerVisibilityListener( + (PlayerView.ControllerVisibilityListener) visibility -> + toolbar.setVisibility(visibility)); + } - // Tap toggles the system bars / toolbar for an immersive look. - findViewById(R.id.player_root).setOnClickListener(v -> { - boolean shown = toolbar.getVisibility() == View.VISIBLE; - toolbar.setVisibility(shown ? View.GONE : View.VISIBLE); + private void initPlayer() { + if (player != null) return; + player = new ExoPlayer.Builder(this).build(); + playerView.setPlayer(player); + player.addListener(new Player.Listener() { + @Override + public void onPlayerError(PlaybackException error) { + Toast.makeText(PlaybackActivity.this, + "Playback error: " + error.getErrorCodeName(), Toast.LENGTH_LONG).show(); + } }); + player.setMediaItem(MediaItem.fromUri(Uri.fromFile(file))); + player.prepare(); + player.setPlayWhenReady(true); + } + + private void releasePlayer() { + if (player != null) { + player.release(); + player = null; + } + } + + @Override + protected void onStart() { + super.onStart(); + initPlayer(); + } + + @Override + protected void onStop() { + super.onStop(); + releasePlayer(); } private void share() { try { Uri uri = FileProvider.getUriForFile( this, getPackageName() + ".fileprovider", file); - android.content.Intent send = new android.content.Intent(android.content.Intent.ACTION_SEND); + Intent send = new Intent(Intent.ACTION_SEND); send.setType("video/mp4"); - send.putExtra(android.content.Intent.EXTRA_STREAM, uri); - send.addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivity(android.content.Intent.createChooser(send, getString(R.string.share_title))); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(send, getString(R.string.share_title))); } catch (Exception e) { Toast.makeText(this, R.string.toast_share_failed, Toast.LENGTH_SHORT).show(); } } - - @Override - protected void onPause() { - super.onPause(); - if (video != null && video.isPlaying()) video.pause(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (video != null) video.stopPlayback(); - } } diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java index 95a4225..6f15aba 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -15,6 +15,10 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; @@ -31,9 +35,11 @@ import java.io.File; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -54,8 +60,11 @@ public class RecordingsActivity extends AppCompatActivity private final ExecutorService work = Executors.newSingleThreadExecutor(); private final Map byStem = new LinkedHashMap<>(); + private final Set prefetching = Collections.synchronizedSet(new HashSet<>()); private RecordingsAdapter adapter; + private MaterialToolbar toolbar; + private RecyclerView list; private View statusDot; private TextView statusText; private MaterialButton btnConnect; @@ -67,7 +76,10 @@ public class RecordingsActivity extends AppCompatActivity private RubyShell shell; private WifiJoiner wifi; private volatile boolean connected; + private boolean compact; private File dir; + private File thumbsDir; + private File cacheDir; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -79,9 +91,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { dir = new File(base, "recordings"); //noinspection ResultOfMethodCallIgnored dir.mkdirs(); + thumbsDir = new File(getFilesDir(), "thumbs"); + //noinspection ResultOfMethodCallIgnored + thumbsDir.mkdirs(); + cacheDir = getCacheDir(); wifi = new WifiJoiner(this); - MaterialToolbar toolbar = findViewById(R.id.toolbar); + SharedPreferences prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE); + compact = prefs.getBoolean("compact", false); + + toolbar = findViewById(R.id.toolbar); toolbar.setOnMenuItemClickListener(this::onMenu); statusDot = findViewById(R.id.status_dot); @@ -94,9 +113,12 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { loading = findViewById(R.id.loading); adapter = new RecordingsAdapter(this); - RecyclerView list = findViewById(R.id.list); - list.setLayoutManager(new LinearLayoutManager(this)); + adapter.setCompact(compact); + list = findViewById(R.id.list); list.setAdapter(adapter); + applyLayoutManager(); + updateLayoutIcon(); + applyInsets(); swipe.setColorSchemeColors(ContextCompat.getColor(this, R.color.ruby)); swipe.setProgressBackgroundColorSchemeColor(ContextCompat.getColor(this, R.color.surface)); @@ -110,15 +132,59 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { else connect(); }); + // Preload any thumbnails we cached on previous sessions. + work.execute(() -> { + Thumbs.loadDiskInto(thumbsDir); + ui.post(() -> adapter.notifyDataSetChanged()); + }); + loadLocal(); setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); } + /** Pad the app bar for the status bar and the list for the nav bar (edge-to-edge). */ + private void applyInsets() { + View appbar = findViewById(R.id.appbar); + ViewCompat.setOnApplyWindowInsetsListener(appbar, (v, insets) -> { + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), bars.top, v.getPaddingRight(), v.getPaddingBottom()); + return insets; + }); + final int basePad = list.getPaddingBottom(); + ViewCompat.setOnApplyWindowInsetsListener(list, (v, insets) -> { + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), + basePad + bars.bottom); + return insets; + }); + } + + private void applyLayoutManager() { + list.setLayoutManager(compact + ? new GridLayoutManager(this, 2) + : new LinearLayoutManager(this)); + } + + private void updateLayoutIcon() { + if (toolbar.getMenu() != null && toolbar.getMenu().findItem(R.id.menu_layout) != null) { + toolbar.getMenu().findItem(R.id.menu_layout) + .setIcon(compact ? R.drawable.ic_list : R.drawable.ic_grid); + } + } + // ── Menu ──────────────────────────────────────────────────────────── private boolean onMenu(android.view.MenuItem item) { int id = item.getItemId(); - if (id == R.id.menu_refresh) { + if (id == R.id.menu_layout) { + compact = !compact; + getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean("compact", compact).apply(); + applyLayoutManager(); + adapter.setCompact(compact); + updateLayoutIcon(); + return true; + } else if (id == R.id.menu_refresh) { if (connected) refresh(); else connect(); return true; } else if (id == R.id.menu_live) { @@ -227,6 +293,39 @@ private void mergeRemote(List remote) { } for (String k : drop) byStem.remove(k); refreshUi(); + prefetchThumbs(); + } + + /** Fetch a small .ts prefix for any clip lacking a cached thumbnail. */ + private void prefetchThumbs() { + if (!connected || shell == null) return; + for (Recording r : byStem.values()) { + if (r.tsName == null) continue; + if (Thumbs.CACHE.get(r.stem) != null) continue; + prefetchThumb(r); + } + } + + private void prefetchThumb(Recording r) { + if (!prefetching.add(r.stem)) return; // already queued + io.execute(() -> { + File part = new File(cacheDir, r.stem + ".tspart"); + try { + if (shell == null || Thumbs.CACHE.get(r.stem) != null) return; + shell.downloadPrefix(r.tsName, 4 * 1024 * 1024, part); + Thumbs.Meta m = Thumbs.extract(part, r.stem); // bitmap only; duration is partial + if (m.bitmap != null) { + Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + ui.post(() -> adapter.update(r)); + } + } catch (Exception ignored) { + // best-effort; clip just shows the placeholder + } finally { + //noinspection ResultOfMethodCallIgnored + part.delete(); + prefetching.remove(r.stem); + } + }); } // ── Local scan ────────────────────────────────────────────────────── @@ -259,6 +358,7 @@ private void extractMeta(Recording r) { final File f = r.localFile; work.execute(() -> { Thumbs.Meta m = Thumbs.extract(f, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); ui.post(() -> { if (m.durationMs > 0) r.durationMs = m.durationMs; adapter.update(r); @@ -324,6 +424,7 @@ private void remux(Recording r, File ts) { playable = ts; // fall back to playing the .ts directly } Thumbs.Meta m = Thumbs.extract(playable, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); ui.post(() -> { r.localFile = playable; r.durationMs = m.durationMs; @@ -381,6 +482,9 @@ private void doDelete(Recording r, boolean drone) { }); } Thumbs.CACHE.remove(r.stem); + File thumb = new File(thumbsDir, r.stem + ".jpg"); + if (thumb.exists()) //noinspection ResultOfMethodCallIgnored + thumb.delete(); byStem.remove(r.stem); refreshUi(); } diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java index 4a94415..e5b5381 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java @@ -5,6 +5,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; +import android.widget.PopupMenu; import android.widget.TextView; import androidx.annotation.NonNull; @@ -19,8 +20,11 @@ public class RecordingsAdapter extends RecyclerView.Adapter { + private static final int TYPE_LARGE = 0; + private static final int TYPE_COMPACT = 1; + public interface Listener { - void onPrimary(Recording r); // download / retry / play + void onPrimary(Recording r); // download / retry void onPlay(Recording r); void onShare(Recording r); void onDelete(Recording r); @@ -28,6 +32,7 @@ public interface Listener { private final List items = new ArrayList<>(); private final Listener listener; + private boolean compact; public RecordingsAdapter(Listener listener) { this.listener = listener; @@ -45,6 +50,22 @@ public void update(Recording r) { if (i >= 0) notifyItemChanged(i); } + public void setCompact(boolean c) { + if (compact != c) { + compact = c; + notifyDataSetChanged(); + } + } + + public boolean isCompact() { + return compact; + } + + @Override + public int getItemViewType(int position) { + return compact ? TYPE_COMPACT : TYPE_LARGE; + } + @Override public long getItemId(int position) { return items.get(position).stem.hashCode(); @@ -53,8 +74,9 @@ public long getItemId(int position) { @NonNull @Override public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_recording, parent, false); + int layout = viewType == TYPE_COMPACT + ? R.layout.item_recording_compact : R.layout.item_recording; + View v = LayoutInflater.from(parent.getContext()).inflate(layout, parent, false); return new VH(v); } @@ -63,15 +85,10 @@ public void onBindViewHolder(@NonNull VH h, int position) { Recording r = items.get(position); h.title.setText(r.stem); - // Thumbnail Bitmap bmp = Thumbs.CACHE.get(r.stem); - if (bmp != null) { - h.thumb.setImageBitmap(bmp); - } else { - h.thumb.setImageDrawable(null); // reveal placeholder background - } + if (bmp != null) h.thumb.setImageBitmap(bmp); + else h.thumb.setImageDrawable(null); - // Duration badge if (r.durationMs > 0) { h.badge.setText(Format.clock(r.durationMs)); h.badge.setVisibility(View.VISIBLE); @@ -92,52 +109,76 @@ public void onBindViewHolder(@NonNull VH h, int position) { h.progress.setIndeterminate(false); h.progress.setProgress(r.progress); h.meta.setText(h.ctx(R.string.state_downloading, r.progress)); - h.primary.setVisibility(View.GONE); break; case REMUXING: h.progress.setVisibility(View.VISIBLE); h.progress.setIndeterminate(true); h.meta.setText(R.string.state_remuxing); - h.primary.setVisibility(View.GONE); - break; - case READY: - h.progress.setVisibility(View.GONE); - h.meta.setText(join(Format.size(shownSize), when)); - h.primary.setVisibility(View.VISIBLE); - h.primary.setText(R.string.action_play); - h.primary.setIconResource(R.drawable.ic_play_arrow); break; case FAILED: h.progress.setVisibility(View.GONE); h.meta.setText(R.string.state_failed); - h.primary.setVisibility(View.VISIBLE); - h.primary.setText(R.string.action_download); - h.primary.setIconResource(R.drawable.ic_download); break; + case READY: case ON_DRONE: default: h.progress.setVisibility(View.GONE); h.meta.setText(join(Format.size(shownSize), when)); - h.primary.setVisibility(View.VISIBLE); - h.primary.setText(R.string.action_download); - h.primary.setIconResource(R.drawable.ic_download); break; } - h.share.setEnabled(ready); - h.delete.setEnabled(r.onDrone || r.isLocal()); + // Primary button only exists in the large layout. + if (h.primary != null) { + boolean busy = r.state == Recording.State.DOWNLOADING + || r.state == Recording.State.REMUXING; + h.primary.setVisibility(busy ? View.GONE : View.VISIBLE); + if (ready) { + h.primary.setText(R.string.action_play); + h.primary.setIconResource(R.drawable.ic_play_arrow); + } else { + h.primary.setText(R.string.action_download); + h.primary.setIconResource(R.drawable.ic_download); + } + h.primary.setOnClickListener(v -> { + if (r.state == Recording.State.READY) listener.onPlay(r); + else listener.onPrimary(r); + }); + h.share.setEnabled(ready); + h.delete.setEnabled(r.onDrone || r.isLocal()); + h.share.setOnClickListener(v -> listener.onShare(r)); + h.delete.setOnClickListener(v -> listener.onDelete(r)); + } - h.primary.setOnClickListener(v -> { - if (r.state == Recording.State.READY) listener.onPlay(r); - else listener.onPrimary(r); - }); - View.OnClickListener playClick = v -> { + View.OnClickListener tap = v -> { if (r.state == Recording.State.READY) listener.onPlay(r); + else if (r.state == Recording.State.ON_DRONE + || r.state == Recording.State.FAILED) listener.onPrimary(r); }; - h.playOverlay.setOnClickListener(playClick); - h.thumb.setOnClickListener(playClick); - h.share.setOnClickListener(v -> listener.onShare(r)); - h.delete.setOnClickListener(v -> listener.onDelete(r)); + h.playOverlay.setOnClickListener(tap); + h.thumb.setOnClickListener(tap); + // In compact mode the whole card acts; long-press always offers the full menu. + if (h.primary == null) h.itemView.setOnClickListener(tap); + h.itemView.setOnLongClickListener(v -> { showContextMenu(v, r); return true; }); + } + + private void showContextMenu(View anchor, Recording r) { + boolean ready = r.state == Recording.State.READY; + PopupMenu pm = new PopupMenu(anchor.getContext(), anchor); + pm.getMenuInflater().inflate(R.menu.item_context, pm.getMenu()); + pm.getMenu().findItem(R.id.ctx_play).setVisible(ready); + pm.getMenu().findItem(R.id.ctx_download).setVisible(!ready && r.onDrone); + pm.getMenu().findItem(R.id.ctx_share).setVisible(ready); + pm.getMenu().findItem(R.id.ctx_delete).setVisible(r.onDrone || r.isLocal()); + pm.setOnMenuItemClickListener(item -> { + int id = item.getItemId(); + if (id == R.id.ctx_play) listener.onPlay(r); + else if (id == R.id.ctx_download) listener.onPrimary(r); + else if (id == R.id.ctx_share) listener.onShare(r); + else if (id == R.id.ctx_delete) listener.onDelete(r); + else return false; + return true; + }); + pm.show(); } private static String join(String a, String b) { @@ -154,7 +195,7 @@ static class VH extends RecyclerView.ViewHolder { final ImageView thumb, playOverlay; final TextView badge, title, meta; final LinearProgressIndicator progress; - final MaterialButton primary, share, delete; + final MaterialButton primary, share, delete; // null in compact layout VH(@NonNull View v) { super(v); diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java index f21ecbc..db0b1af 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java @@ -154,6 +154,32 @@ public long download(String remoteName, long expectedSize, File dest, Progress c return total; } + /** + * Pull only the first {@code bytes} of a remote file (for a thumbnail). The + * clip starts on a keyframe, so the first few MB contain a decodable frame. + */ + public long downloadPrefix(String remoteName, int bytes, File dest) throws Exception { + String cmd = "head -c " + bytes + " '" + REMOTE_DIR + "/" + remoteName + "'"; + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + ch.setCommand(cmd); + ch.setInputStream(null); + InputStream in = ch.getInputStream(); + long total = 0; + try (OutputStream fout = new FileOutputStream(dest)) { + ch.connect(); + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) != -1) { + fout.write(buf, 0, n); + total += n; + } + fout.flush(); + } finally { + disconnect(ch); + } + return total; + } + /** Delete one or more files from the recordings dir. */ public void delete(String... names) throws Exception { StringBuilder sb = new StringBuilder("rm -f"); diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java index a442aeb..459acd9 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -1,10 +1,12 @@ package com.rubyfpv.viewer.recordings; import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.media.MediaMetadataRetriever; import android.util.LruCache; import java.io.File; +import java.io.FileOutputStream; /** First-frame thumbnails (DJI-style) + duration, via MediaMetadataRetriever. */ public final class Thumbs { @@ -53,6 +55,32 @@ public static Meta extract(File file, String stem) { return m; } + /** Persist a thumbnail as JPEG so it survives app restarts (true preload). */ + public static void saveDisk(File thumbsDir, String stem, Bitmap bmp) { + if (bmp == null) return; + //noinspection ResultOfMethodCallIgnored + thumbsDir.mkdirs(); + File f = new File(thumbsDir, stem + ".jpg"); + try (FileOutputStream out = new FileOutputStream(f)) { + bmp.compress(Bitmap.CompressFormat.JPEG, 80, out); + } catch (Exception ignored) { + } + } + + /** Load all on-disk thumbnails into the in-memory cache at startup. */ + public static void loadDiskInto(File thumbsDir) { + File[] files = thumbsDir.listFiles(); + if (files == null) return; + for (File f : files) { + String name = f.getName(); + if (!name.endsWith(".jpg")) continue; + String stem = name.substring(0, name.length() - 4); + if (CACHE.get(stem) != null) continue; + Bitmap b = BitmapFactory.decodeFile(f.getAbsolutePath()); + if (b != null) CACHE.put(stem, b); + } + } + private static Bitmap scale(Bitmap src) { int w = src.getWidth(); int h = src.getHeight(); diff --git a/app/src/main/res/drawable/ic_grid.xml b/app/src/main/res/drawable/ic_grid.xml new file mode 100644 index 0000000..34b4e5d --- /dev/null +++ b/app/src/main/res/drawable/ic_grid.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml new file mode 100644 index 0000000..efe3249 --- /dev/null +++ b/app/src/main/res/drawable/ic_list.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml index d65dfc2..358d75d 100644 --- a/app/src/main/res/layout/activity_playback.xml +++ b/app/src/main/res/layout/activity_playback.xml @@ -6,11 +6,13 @@ android:layout_height="match_parent" android:background="@android:color/black"> - + app:resize_mode="fit" + app:show_buffering="when_playing" + app:use_controller="true" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/item_context.xml b/app/src/main/res/menu/item_context.xml new file mode 100644 index 0000000..cb198df --- /dev/null +++ b/app/src/main/res/menu/item_context.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/menu/recordings.xml b/app/src/main/res/menu/recordings.xml index 50de0f2..31f468d 100644 --- a/app/src/main/res/menu/recordings.xml +++ b/app/src/main/res/menu/recordings.xml @@ -2,6 +2,12 @@ + + Live view (USB) Connection settings Refresh + Grid / list Connect Disconnect diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9d89e2..f0df197 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ material = "1.12.0" constraintlayout = "2.1.4" swiperefresh = "1.1.0" jsch = "0.2.23" +media3 = "1.4.1" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -13,3 +14,5 @@ material = { group = "com.google.android.material", name = "material", version.r constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefresh" } jsch = { group = "com.github.mwiede", name = "jsch", version.ref = "jsch" } +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } From ff6726345471d9bdce5bc270679141a023a7874c Mon Sep 17 00:00:00 2001 From: wkumik Date: Sat, 13 Jun 2026 15:17:10 +0200 Subject: [PATCH 3/7] Fix thumbnail preload (MediaCodec decode) + APK versioning - Thumbnails: replace MediaMetadataRetriever with a MediaExtractor + MediaCodec first-frame decoder. MMR can't decode MPEG-TS on Android (returns null), which is why preload from a partial .ts prefix produced no thumbnails. The codec path is container-agnostic and works on both partial .ts and full .mp4. Requests COLOR_FormatYUV420Flexible and converts YUV_420_888 -> NV21 -> Bitmap. - Versioning: bump to v1.2 (code 3); APK filename now RubyFPV-v-b-.apk and the version shows as the toolbar subtitle (BuildConfig). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 15 +- .../viewer/recordings/RecordingsActivity.java | 2 + .../com/rubyfpv/viewer/recordings/Thumbs.java | 145 ++++++++++++++++-- 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2b021bf..15554ff 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,12 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 2 - versionName "1.1" + versionCode 3 + versionName "1.2" + } + + buildFeatures { + buildConfig = true } buildTypes { @@ -26,6 +30,13 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + // Stamp the version into the APK filename, e.g. RubyFPV-v1.2-b3-debug.apk + applicationVariants.all { variant -> + variant.outputs.all { output -> + outputFileName = "RubyFPV-v${variant.versionName}-b${variant.versionCode}-${variant.buildType.name}.apk" + } + } } dependencies { diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java index 6f15aba..3d4b4bc 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -101,6 +101,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { compact = prefs.getBoolean("compact", false); toolbar = findViewById(R.id.toolbar); + toolbar.setSubtitle("v" + com.rubyfpv.viewer.BuildConfig.VERSION_NAME + + " (" + com.rubyfpv.viewer.BuildConfig.VERSION_CODE + ")"); toolbar.setOnMenuItemClickListener(this::onMenu); statusDot = findViewById(R.id.status_dot); diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java index 459acd9..e21348b 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -2,13 +2,30 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; -import android.media.MediaMetadataRetriever; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.media.Image; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaExtractor; +import android.media.MediaFormat; import android.util.LruCache; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; +import java.nio.ByteBuffer; -/** First-frame thumbnails (DJI-style) + duration, via MediaMetadataRetriever. */ +/** + * First-frame thumbnails (DJI-style) + duration. + * + *

Decodes via {@link MediaExtractor} + {@link MediaCodec} rather than + * {@link android.media.MediaMetadataRetriever}, because MMR cannot decode + * MPEG-TS on Android (returns null frames) — and the preload path feeds it a + * partial {@code .ts} prefix. MediaCodec handles both partial {@code .ts} and + * full {@code .mp4} uniformly.

+ */ public final class Thumbs { private static final int MAX_W = 640; @@ -34,23 +51,77 @@ public static class Meta { public static Meta extract(File file, String stem) { Meta m = new Meta(); - MediaMetadataRetriever r = new MediaMetadataRetriever(); + MediaExtractor ex = new MediaExtractor(); + MediaCodec codec = null; try { - r.setDataSource(file.getAbsolutePath()); - String dur = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); - if (dur != null) { - try { m.durationMs = Long.parseLong(dur); } catch (NumberFormatException ignored) {} + ex.setDataSource(file.getAbsolutePath()); + int track = -1; + MediaFormat fmt = null; + for (int i = 0; i < ex.getTrackCount(); i++) { + MediaFormat f = ex.getTrackFormat(i); + String mime = f.getString(MediaFormat.KEY_MIME); + if (mime != null && mime.startsWith("video/")) { track = i; fmt = f; break; } } - Bitmap frame = r.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC); - if (frame != null) { - m.bitmap = scale(frame); - if (m.bitmap != frame) frame.recycle(); + if (track < 0) return m; + ex.selectTrack(track); + if (fmt.containsKey(MediaFormat.KEY_DURATION)) { + m.durationMs = fmt.getLong(MediaFormat.KEY_DURATION) / 1000; + } + + String mime = fmt.getString(MediaFormat.KEY_MIME); + fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible); + codec = MediaCodec.createDecoderByType(mime); + codec.configure(fmt, null, null, 0); // ByteBuffer mode → getOutputImage() + codec.start(); + + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + boolean inputDone = false; + Bitmap bmp = null; + int guard = 0; + while (bmp == null && guard++ < 3000) { + if (!inputDone) { + int inIdx = codec.dequeueInputBuffer(10000); + if (inIdx >= 0) { + ByteBuffer ib = codec.getInputBuffer(inIdx); + int size = ib == null ? -1 : ex.readSampleData(ib, 0); + if (size < 0) { + codec.queueInputBuffer(inIdx, 0, 0, 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } else { + codec.queueInputBuffer(inIdx, 0, size, ex.getSampleTime(), 0); + ex.advance(); + } + } + } + int outIdx = codec.dequeueOutputBuffer(info, 10000); + if (outIdx >= 0) { + if (info.size > 0) { + Image img = codec.getOutputImage(outIdx); + if (img != null) { + bmp = imageToBitmap(img); + img.close(); + } + } + codec.releaseOutputBuffer(outIdx, false); + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) break; + } + } + + if (bmp != null) { + m.bitmap = scale(bmp); + if (m.bitmap != bmp) bmp.recycle(); if (stem != null && m.bitmap != null) CACHE.put(stem, m.bitmap); } } catch (Exception ignored) { - // unreadable clip — leave bitmap null, caller shows placeholder + // unreadable / unsupported — caller shows the placeholder } finally { - try { r.release(); } catch (Exception ignored) {} + if (codec != null) { + try { codec.stop(); } catch (Exception ignored) {} + try { codec.release(); } catch (Exception ignored) {} + } + try { ex.release(); } catch (Exception ignored) {} } return m; } @@ -81,6 +152,54 @@ public static void loadDiskInto(File thumbsDir) { } } + // ── YUV_420_888 → Bitmap ──────────────────────────────────────────── + + private static Bitmap imageToBitmap(Image image) { + int w = image.getWidth(); + int h = image.getHeight(); + byte[] nv21 = yuv420ToNv21(image, w, h); + YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, w, h, null); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + yuv.compressToJpeg(new Rect(0, 0, w, h), 90, out); + byte[] jpeg = out.toByteArray(); + return BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); + } + + private static byte[] yuv420ToNv21(Image image, int width, int height) { + Image.Plane[] planes = image.getPlanes(); + byte[] nv21 = new byte[width * height * 3 / 2]; + + // Y plane + ByteBuffer yBuf = planes[0].getBuffer(); + int yRowStride = planes[0].getRowStride(); + int pos = 0; + for (int row = 0; row < height; row++) { + int rowStart = row * yRowStride; + yBuf.position(rowStart); + yBuf.get(nv21, pos, width); + pos += width; + } + + // Interleave V,U into NV21 (VU order) + ByteBuffer uBuf = planes[1].getBuffer(); + ByteBuffer vBuf = planes[2].getBuffer(); + int uRowStride = planes[1].getRowStride(); + int uPixStride = planes[1].getPixelStride(); + int vRowStride = planes[2].getRowStride(); + int vPixStride = planes[2].getPixelStride(); + int cw = width / 2; + int ch = height / 2; + for (int row = 0; row < ch; row++) { + for (int col = 0; col < cw; col++) { + int vIndex = row * vRowStride + col * vPixStride; + int uIndex = row * uRowStride + col * uPixStride; + nv21[pos++] = vBuf.get(vIndex); + nv21[pos++] = uBuf.get(uIndex); + } + } + return nv21; + } + private static Bitmap scale(Bitmap src) { int w = src.getWidth(); int h = src.getHeight(); From ec11ab3be745f716a8b7f69a78ebc97f7fad6554 Mon Sep 17 00:00:00 2001 From: wkumik Date: Sat, 13 Jun 2026 15:29:37 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Thumbnails:=20layered=20extract=20(MMR=20->?= =?UTF-8?q?=20remux+MMR=20->=20codec)=20=E2=80=94=20fix=20preload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure MediaCodec decode (v1.2) produced no thumbnails on device. Make extract try the most-proven paths first: MMR direct (mp4), then remux the .ts/prefix to a tiny mp4 via the same Remuxer that builds playable clips and run MMR on that (validated: a 4MB .ts prefix -c copy -> 166-frame mp4, frame 0 extracts), then MediaCodec as a last resort. Adds a Log line noting which path won. Bump v1.3 (code 4). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 4 +- .../com/rubyfpv/viewer/recordings/Thumbs.java | 184 ++++++++++++------ 2 files changed, 123 insertions(+), 65 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 15554ff..f39ddff 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 3 - versionName "1.2" + versionCode 4 + versionName "1.3" } buildFeatures { diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java index e21348b..6216738 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -10,6 +10,8 @@ import android.media.MediaCodecInfo; import android.media.MediaExtractor; import android.media.MediaFormat; +import android.media.MediaMetadataRetriever; +import android.util.Log; import android.util.LruCache; import java.io.ByteArrayOutputStream; @@ -20,14 +22,19 @@ /** * First-frame thumbnails (DJI-style) + duration. * - *

Decodes via {@link MediaExtractor} + {@link MediaCodec} rather than - * {@link android.media.MediaMetadataRetriever}, because MMR cannot decode - * MPEG-TS on Android (returns null frames) — and the preload path feeds it a - * partial {@code .ts} prefix. MediaCodec handles both partial {@code .ts} and - * full {@code .mp4} uniformly.

+ *

Android's {@link MediaMetadataRetriever} can't decode MPEG-TS (returns null), + * and the preload path feeds a partial {@code .ts} prefix. So {@link #extract} + * tries, in order of reliability:

+ *
    + *
  1. MMR directly — works for {@code .mp4};
  2. + *
  3. remux the input to a tiny {@code .mp4} (same {@link Remuxer} that builds the + * playable clips) then MMR — the workhorse for {@code .ts};
  4. + *
  5. a {@link MediaCodec} first-frame decode as a last resort.
  6. + *
*/ public final class Thumbs { + private static final String TAG = "Thumbs"; private static final int MAX_W = 640; public static final LruCache CACHE; @@ -51,6 +58,99 @@ public static class Meta { public static Meta extract(File file, String stem) { Meta m = new Meta(); + + // 1) MMR directly (works for .mp4) + m.bitmap = mmrFrame(file); + m.durationMs = mmrDuration(file); + String how = "mmr"; + + // 2) remux to a small .mp4 then MMR (the .ts / prefix path) + if (m.bitmap == null) { + File tmp = new File(file.getAbsolutePath() + ".thumb.mp4"); + try { + if (Remuxer.remux(file, tmp)) { + m.bitmap = mmrFrame(tmp); + if (m.durationMs <= 0) m.durationMs = mmrDuration(tmp); + how = "remux+mmr"; + } + } finally { + //noinspection ResultOfMethodCallIgnored + tmp.delete(); + } + } + + // 3) MediaCodec first-frame decode + if (m.bitmap == null) { + m.bitmap = codecFrame(file); + how = "codec"; + } + + if (m.bitmap != null) { + m.bitmap = scale(m.bitmap); + if (stem != null) CACHE.put(stem, m.bitmap); + Log.i(TAG, "thumb ok via " + how + " for " + (stem != null ? stem : file.getName())); + } else { + Log.w(TAG, "thumb FAILED (all paths) for " + (stem != null ? stem : file.getName())); + } + return m; + } + + // ── MediaMetadataRetriever ────────────────────────────────────────── + + private static Bitmap mmrFrame(File file) { + MediaMetadataRetriever r = new MediaMetadataRetriever(); + try { + r.setDataSource(file.getAbsolutePath()); + return r.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC); + } catch (Exception e) { + return null; + } finally { + try { r.release(); } catch (Exception ignored) {} + } + } + + private static long mmrDuration(File file) { + MediaMetadataRetriever r = new MediaMetadataRetriever(); + try { + r.setDataSource(file.getAbsolutePath()); + String d = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + return d == null ? -1 : Long.parseLong(d); + } catch (Exception e) { + return -1; + } finally { + try { r.release(); } catch (Exception ignored) {} + } + } + + // ── Persistence ───────────────────────────────────────────────────── + + public static void saveDisk(File thumbsDir, String stem, Bitmap bmp) { + if (bmp == null) return; + //noinspection ResultOfMethodCallIgnored + thumbsDir.mkdirs(); + File f = new File(thumbsDir, stem + ".jpg"); + try (FileOutputStream out = new FileOutputStream(f)) { + bmp.compress(Bitmap.CompressFormat.JPEG, 80, out); + } catch (Exception ignored) { + } + } + + public static void loadDiskInto(File thumbsDir) { + File[] files = thumbsDir.listFiles(); + if (files == null) return; + for (File f : files) { + String name = f.getName(); + if (!name.endsWith(".jpg")) continue; + String stem = name.substring(0, name.length() - 4); + if (CACHE.get(stem) != null) continue; + Bitmap b = BitmapFactory.decodeFile(f.getAbsolutePath()); + if (b != null) CACHE.put(stem, b); + } + } + + // ── MediaCodec first-frame decode (fallback) ──────────────────────── + + private static Bitmap codecFrame(File file) { MediaExtractor ex = new MediaExtractor(); MediaCodec codec = null; try { @@ -62,24 +162,20 @@ public static Meta extract(File file, String stem) { String mime = f.getString(MediaFormat.KEY_MIME); if (mime != null && mime.startsWith("video/")) { track = i; fmt = f; break; } } - if (track < 0) return m; + if (track < 0) return null; ex.selectTrack(track); - if (fmt.containsKey(MediaFormat.KEY_DURATION)) { - m.durationMs = fmt.getLong(MediaFormat.KEY_DURATION) / 1000; - } String mime = fmt.getString(MediaFormat.KEY_MIME); fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible); codec = MediaCodec.createDecoderByType(mime); - codec.configure(fmt, null, null, 0); // ByteBuffer mode → getOutputImage() + codec.configure(fmt, null, null, 0); codec.start(); MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); boolean inputDone = false; - Bitmap bmp = null; int guard = 0; - while (bmp == null && guard++ < 3000) { + while (guard++ < 3000) { if (!inputDone) { int inIdx = codec.dequeueInputBuffer(10000); if (inIdx >= 0) { @@ -97,25 +193,19 @@ public static Meta extract(File file, String stem) { } int outIdx = codec.dequeueOutputBuffer(info, 10000); if (outIdx >= 0) { + Bitmap bmp = null; if (info.size > 0) { Image img = codec.getOutputImage(outIdx); - if (img != null) { - bmp = imageToBitmap(img); - img.close(); - } + if (img != null) { bmp = imageToBitmap(img); img.close(); } } codec.releaseOutputBuffer(outIdx, false); - if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) break; + if (bmp != null) return bmp; + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) return null; } } - - if (bmp != null) { - m.bitmap = scale(bmp); - if (m.bitmap != bmp) bmp.recycle(); - if (stem != null && m.bitmap != null) CACHE.put(stem, m.bitmap); - } - } catch (Exception ignored) { - // unreadable / unsupported — caller shows the placeholder + return null; + } catch (Exception e) { + return null; } finally { if (codec != null) { try { codec.stop(); } catch (Exception ignored) {} @@ -123,37 +213,8 @@ public static Meta extract(File file, String stem) { } try { ex.release(); } catch (Exception ignored) {} } - return m; - } - - /** Persist a thumbnail as JPEG so it survives app restarts (true preload). */ - public static void saveDisk(File thumbsDir, String stem, Bitmap bmp) { - if (bmp == null) return; - //noinspection ResultOfMethodCallIgnored - thumbsDir.mkdirs(); - File f = new File(thumbsDir, stem + ".jpg"); - try (FileOutputStream out = new FileOutputStream(f)) { - bmp.compress(Bitmap.CompressFormat.JPEG, 80, out); - } catch (Exception ignored) { - } - } - - /** Load all on-disk thumbnails into the in-memory cache at startup. */ - public static void loadDiskInto(File thumbsDir) { - File[] files = thumbsDir.listFiles(); - if (files == null) return; - for (File f : files) { - String name = f.getName(); - if (!name.endsWith(".jpg")) continue; - String stem = name.substring(0, name.length() - 4); - if (CACHE.get(stem) != null) continue; - Bitmap b = BitmapFactory.decodeFile(f.getAbsolutePath()); - if (b != null) CACHE.put(stem, b); - } } - // ── YUV_420_888 → Bitmap ──────────────────────────────────────────── - private static Bitmap imageToBitmap(Image image) { int w = image.getWidth(); int h = image.getHeight(); @@ -169,18 +230,15 @@ private static byte[] yuv420ToNv21(Image image, int width, int height) { Image.Plane[] planes = image.getPlanes(); byte[] nv21 = new byte[width * height * 3 / 2]; - // Y plane ByteBuffer yBuf = planes[0].getBuffer(); int yRowStride = planes[0].getRowStride(); int pos = 0; for (int row = 0; row < height; row++) { - int rowStart = row * yRowStride; - yBuf.position(rowStart); + yBuf.position(row * yRowStride); yBuf.get(nv21, pos, width); pos += width; } - // Interleave V,U into NV21 (VU order) ByteBuffer uBuf = planes[1].getBuffer(); ByteBuffer vBuf = planes[2].getBuffer(); int uRowStride = planes[1].getRowStride(); @@ -191,10 +249,8 @@ private static byte[] yuv420ToNv21(Image image, int width, int height) { int ch = height / 2; for (int row = 0; row < ch; row++) { for (int col = 0; col < cw; col++) { - int vIndex = row * vRowStride + col * vPixStride; - int uIndex = row * uRowStride + col * uPixStride; - nv21[pos++] = vBuf.get(vIndex); - nv21[pos++] = uBuf.get(uIndex); + nv21[pos++] = vBuf.get(row * vRowStride + col * vPixStride); + nv21[pos++] = uBuf.get(row * uRowStride + col * uPixStride); } } return nv21; @@ -205,6 +261,8 @@ private static Bitmap scale(Bitmap src) { int h = src.getHeight(); if (w <= MAX_W || w == 0) return src; float ratio = (float) MAX_W / w; - return Bitmap.createScaledBitmap(src, MAX_W, Math.round(h * ratio), true); + Bitmap scaled = Bitmap.createScaledBitmap(src, MAX_W, Math.round(h * ratio), true); + if (scaled != src) src.recycle(); + return scaled; } } From c78e1d41688d6a1ac13341c90f0cfd16e34ef3e9 Mon Sep 17 00:00:00 2001 From: wkumik Date: Sat, 13 Jun 2026 19:38:34 +0200 Subject: [PATCH 5/7] Recordings: in-app Diagnostics viewer + on-device DebugLog (v1.4) Thumbnails never load on Fairphone 6 / Android 16; logcat shows MediaExtractor.setDataSource failing for every thumbnail path. To decide between bad input files vs. an A16 MediaExtractor limitation we need to read the `extract ( B)` instrumentation, but the USB/adb link is flaky and A16 blocks `adb shell ls` of the app's external files dir. - DebugLog: tiny in-app ring buffer (already feeding Thumbs/Remuxer). - RecordingsActivity: menu -> Diagnostics opens a scrollable, monospaced dump with Copy (clipboard) / Clear / Close so the pipeline can be inspected and pasted out without adb. - Bump versionCode 5 / versionName 1.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 4 +- .../rubyfpv/viewer/recordings/DebugLog.java | 40 ++++++++++++++++++ .../viewer/recordings/RecordingsActivity.java | 42 +++++++++++++++++++ .../rubyfpv/viewer/recordings/Remuxer.java | 1 + .../com/rubyfpv/viewer/recordings/Thumbs.java | 13 ++++-- app/src/main/res/menu/recordings.xml | 6 +++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java diff --git a/app/build.gradle b/app/build.gradle index f39ddff..8a5bc35 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 4 - versionName "1.3" + versionCode 5 + versionName "1.4" } buildFeatures { diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java b/app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java new file mode 100644 index 0000000..27c1561 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/DebugLog.java @@ -0,0 +1,40 @@ +package com.rubyfpv.viewer.recordings; + +import android.os.SystemClock; +import android.util.Log; + +import java.util.ArrayDeque; +import java.util.Locale; + +/** + * Tiny in-app ring-buffer log so the thumbnail/transfer pipeline can be inspected + * on-device (⋮ → Diagnostics) without needing adb/logcat. + */ +public final class DebugLog { + + private static final String TAG = "RubyDiag"; + private static final int MAX = 500; + private static final ArrayDeque LINES = new ArrayDeque<>(); + private static final long T0 = SystemClock.uptimeMillis(); + + private DebugLog() {} + + public static synchronized void add(String msg) { + String line = String.format(Locale.US, "%6.1fs %s", + (SystemClock.uptimeMillis() - T0) / 1000.0, msg); + LINES.addLast(line); + while (LINES.size() > MAX) LINES.removeFirst(); + Log.i(TAG, msg); + } + + public static synchronized String dump() { + if (LINES.isEmpty()) return "(no diagnostics yet — connect and let thumbnails load)"; + StringBuilder sb = new StringBuilder(); + for (String s : LINES) sb.append(s).append('\n'); + return sb.toString(); + } + + public static synchronized void clear() { + LINES.clear(); + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java index 3d4b4bc..773a88b 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -1,5 +1,7 @@ package com.rubyfpv.viewer.recordings; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -7,7 +9,9 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.util.TypedValue; import android.view.View; +import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; @@ -195,10 +199,48 @@ private boolean onMenu(android.view.MenuItem item) { } else if (id == R.id.menu_connection) { showConnectionDialog(); return true; + } else if (id == R.id.menu_diagnostics) { + showDiagnosticsDialog(); + return true; } return false; } + /** Show the in-app {@link DebugLog} ring buffer so the thumbnail/transfer pipeline + * can be inspected on-device (no adb), with a Copy button to paste it back out. */ + private void showDiagnosticsDialog() { + final String dump = DebugLog.dump(); + + TextView tv = new TextView(this); + tv.setText(dump); + tv.setTextIsSelectable(true); + tv.setTypeface(android.graphics.Typeface.MONOSPACE); + tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11); + int pad = Math.round(16 * getResources().getDisplayMetrics().density); + tv.setPadding(pad, pad, pad, pad); + + ScrollView sv = new ScrollView(this); + sv.addView(tv); + + new MaterialAlertDialogBuilder(this) + .setTitle("Diagnostics") + .setView(sv) + .setNeutralButton("Clear", (d, w) -> { + DebugLog.clear(); + Toast.makeText(this, "Diagnostics cleared", Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton("Copy", (d, w) -> { + ClipboardManager cb = + (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (cb != null) { + cb.setPrimaryClip(ClipData.newPlainText("RubyFPV diagnostics", dump)); + Toast.makeText(this, "Copied to clipboard", Toast.LENGTH_SHORT).show(); + } + }) + .setPositiveButton("Close", null) + .show(); + } + // ── Connection ────────────────────────────────────────────────────── private void connect() { diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java index d2db8a1..60b5159 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java @@ -32,6 +32,7 @@ public static boolean remux(File src, File dst) { try { extractor.setDataSource(src.getAbsolutePath()); int trackCount = extractor.getTrackCount(); + DebugLog.add(" remux: extractor tracks=" + trackCount); int[] muxIndex = new int[trackCount]; int maxInput = 1 << 20; // 1 MB floor diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java index 6216738..ae38051 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -58,20 +58,26 @@ public static class Meta { public static Meta extract(File file, String stem) { Meta m = new Meta(); + String id = stem != null ? stem : file.getName(); + DebugLog.add("extract " + id + " (" + file.length() + " B)"); // 1) MMR directly (works for .mp4) m.bitmap = mmrFrame(file); m.durationMs = mmrDuration(file); String how = "mmr"; + DebugLog.add(" mmr-direct: " + (m.bitmap != null ? "OK" : "null")); // 2) remux to a small .mp4 then MMR (the .ts / prefix path) if (m.bitmap == null) { File tmp = new File(file.getAbsolutePath() + ".thumb.mp4"); try { - if (Remuxer.remux(file, tmp)) { + boolean rx = Remuxer.remux(file, tmp); + DebugLog.add(" remux: " + (rx ? ("OK " + tmp.length() + " B") : "FAIL")); + if (rx) { m.bitmap = mmrFrame(tmp); if (m.durationMs <= 0) m.durationMs = mmrDuration(tmp); how = "remux+mmr"; + DebugLog.add(" mmr-on-remux: " + (m.bitmap != null ? "OK" : "null")); } } finally { //noinspection ResultOfMethodCallIgnored @@ -83,14 +89,15 @@ public static Meta extract(File file, String stem) { if (m.bitmap == null) { m.bitmap = codecFrame(file); how = "codec"; + DebugLog.add(" codec: " + (m.bitmap != null ? "OK" : "null")); } if (m.bitmap != null) { m.bitmap = scale(m.bitmap); if (stem != null) CACHE.put(stem, m.bitmap); - Log.i(TAG, "thumb ok via " + how + " for " + (stem != null ? stem : file.getName())); + DebugLog.add(" => thumb OK via " + how + " for " + id); } else { - Log.w(TAG, "thumb FAILED (all paths) for " + (stem != null ? stem : file.getName())); + DebugLog.add(" => thumb FAILED (all paths) for " + id); } return m; } diff --git a/app/src/main/res/menu/recordings.xml b/app/src/main/res/menu/recordings.xml index 31f468d..5303af5 100644 --- a/app/src/main/res/menu/recordings.xml +++ b/app/src/main/res/menu/recordings.xml @@ -25,4 +25,10 @@ android:icon="@drawable/ic_settings" android:title="@string/menu_connection" app:showAsAction="never" /> + +
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 828283e..f6dc782 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Recordings Live view (USB) Connection settings + Diagnostics Refresh Grid / list From c95d3866e0ff25528565f34122785719af061a59 Mon Sep 17 00:00:00 2001 From: wkumik Date: Sun, 14 Jun 2026 07:14:50 +0200 Subject: [PATCH 6/7] Recordings rework + Return to FPV mode (v1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.5 — recordings screen rework: - Thumbnails and playback via media3 1.6.0 ExperimentalFrameExtractor, which decodes HEVC-in-MPEG-TS where the Android framework demuxer reports 0 tracks. - Play the downloaded .ts directly in ExoPlayer; drop framework remux entirely (delete Remuxer.java, no more .ts->.mp4). - Multi-select (long-press) with bulk delete. - Per-item delete choice: phone / drone SD card / both. v1.6 — Return to FPV mode: - New overflow action: RubyShell.returnToFpv() runs /usr/bin/ap_mode.sh stop, rebooting the drone out of phone-transfer AP mode back into normal FPV. - Disconnect stays lightweight (drops the SSH session only). - versionCode 7->8, versionName 1.5->1.6. Also update README to document the phone-transfer workflow, feature list, build/install instructions, and the no-SFTP dropbear exec/cat transfer design. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 141 +++++----- app/build.gradle | 5 +- .../rubyfpv/viewer/recordings/Recording.java | 12 +- .../viewer/recordings/RecordingsActivity.java | 247 +++++++++++++++--- .../viewer/recordings/RecordingsAdapter.java | 114 ++++++-- .../rubyfpv/viewer/recordings/Remuxer.java | 106 -------- .../rubyfpv/viewer/recordings/RubyShell.java | 24 ++ .../com/rubyfpv/viewer/recordings/Thumbs.java | 225 +++------------- app/src/main/res/layout/item_recording.xml | 14 + .../res/layout/item_recording_compact.xml | 13 + app/src/main/res/menu/recordings.xml | 13 + app/src/main/res/values/strings.xml | 20 ++ gradle/libs.versions.toml | 3 +- 13 files changed, 509 insertions(+), 428 deletions(-) delete mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java diff --git a/README.md b/README.md index 4d9b90d..c84d6ed 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,104 @@ # RubyFPV Android Viewer -Android companion app for [RubyFPV](https://rubyfpv.com). Two modes: - -1. **Recordings** (primary) — DJI-style transfer of onboard HEVC recordings off the - drone over its Wi-Fi, with on-device preview and sharing. -2. **Live view** (secondary) — the live H.264 feed from a Ruby ground station over - USB-C tethering. - -## Recordings — download & preview - -When the drone is in **phone-transfer mode** (its radio comes up as a Wi-Fi AP), the -app connects over SSH and lets you browse the onboard SD card: - -1. Join the drone's Wi-Fi and tap **Connect** -2. Browse recorded clips with thumbnails, duration, size and date -3. **Download** a clip — it transfers over Wi-Fi and is losslessly rewrapped from - `.ts` to `.mp4` on-device (zero re-encode) for reliable seeking and sharing -4. **Play** it natively (hardware HEVC decode) or **Share** to anything - -Transfer uses an SSH `exec` + `cat` stream rather than SFTP, because the drone's -dropbear build ships no SFTP/SCP subsystem. - -## Live view — how it works - -1. Phone connects to Ruby ground station via USB cable -2. USB tethering is enabled on the phone (creates a network link) -3. Ruby detects the phone and sends raw H.264 video over UDP port 5001 -4. The app decodes and displays the video in fullscreen with minimal latency - -(Open it from the **⋮ → Live view (USB)** menu on the Recordings screen.) +Android companion app for [RubyFPV](https://rubyfpv.com). Browse, download, play and +manage the onboard `.ts` recordings off your drone's SD card over Wi-Fi — DJI-Fly style — +then tap **Return to FPV mode** to reboot the drone back to flight. A live USB viewer +(the original `MainActivity`) is still available behind the ⋮ menu. + +## Phone-transfer workflow + +1. **On the Ruby Ground Station**, press **Enter phone-transfer mode**. The drone reboots + its radio into an open Wi-Fi access point named `RubyFPV-` at `192.168.4.1`. +2. **On your phone**, join that Wi-Fi network. +3. Open the app and tap **Connect**. It SSHes the drone and lists the onboard recordings. +4. Browse clips with thumbnails, **download**, **play**, and **delete** as you like. +5. When you're done, tap **⋮ → Return to FPV mode**. The drone reboots out of + phone-transfer AP mode back into normal flight/FPV (ready to fly again in ~45 s). + +The drone runs **dropbear**, whose build ships **no SFTP / SCP subsystem**, so the app +never opens an SFTP channel. It drives plain `exec` channels instead: listing with +`stat`, transferring by streaming `cat`. Recordings live in `/mnt/mmcblk0p1/ruby` and the +default login is `root@192.168.4.1:22` (editable in **⋮ → Connection settings**). + +## Recordings — download & playback + +- **Thumbnails** are decoded on-device from the first few MB of each clip (it starts on a + keyframe). The onboard recordings are **HEVC in MPEG-TS**, which the Android framework + demuxer reports as 0 tracks; the app uses media3 1.6.0's + `ExperimentalFrameExtractor` — the same ExoPlayer pipeline used for playback — to pull + the first frame. +- **Playback** plays the downloaded `.ts` directly in ExoPlayer (no remux / re-encode). +- **Multi-select**: long-press a clip to enter selection mode, then bulk-delete. +- **Per-item delete** lets you choose where to remove a clip from: **phone**, **drone SD + card**, or **both**. +- **Share** a downloaded clip to any app via the system share sheet. + +## Live USB viewer + +The original USB viewer is still present, reachable from **⋮ → Live view (USB)**: + +1. Connect the phone to the Ruby ground station via USB cable and enable USB tethering. +2. Ruby forwards raw H.264 video over UDP port 5001. +3. The app decodes and displays it fullscreen with minimal latency. + +To enable forwarding on the ground station: **Controller > Video Forward**, turn on +**Video Forward To USB Device**, type **Raw (H264)**, port **5001**. ## Features -- DJI-style recordings album: thumbnails, lossless `.ts`→`.mp4` remux, share sheet -- Hardware H.264 / HEVC decoding via Android MediaCodec / MediaPlayer -- Fullscreen landscape live display, auto-recovery on stream loss (3-second watchdog) -- Live stream stats overlay (tap screen to toggle): bitrate, packet rate, NAL rate -- Minimal latency — designed for FPV use +- Recordings browser with thumbnails, file size, date and on-drone / on-device state +- ExoPlayer playback of `.ts` HEVC clips (hardware decode, no remux) +- Multi-select (long-press) with bulk delete +- Per-item delete: phone / drone SD card / both +- Share downloaded clips via the system share sheet +- **Return to FPV mode** — reboots the drone out of phone-transfer AP back into flight +- Live USB viewer (raw H.264 over UDP 5001) behind the ⋮ menu +- In-app diagnostics / debug log viewer ## Requirements -- Android 7.0+ (API 24) -- USB data cable (not charge-only) -- RubyFPV ground station with USB video forwarding enabled +- Android 7.0+ (min SDK 24, target SDK 35) +- A RubyFPV drone that supports phone-transfer mode (open AP `RubyFPV-` @ 192.168.4.1) +- For the live USB viewer: a USB data cable and a Ruby ground station with USB video + forwarding enabled -## Ruby ground station setup +## Build from source -1. Go to **Controller > Video Forward** in the Ruby menu -2. Enable **Video Forward To USB Device** -3. Set type to **Raw (H264)** -4. Port: **5001** (default) +Requires **Java 21** and the Android SDK (`ANDROID_HOME` set, or a `local.properties` +with `sdk.dir`). -## Install - -Download the latest APK from [GitHub Actions](https://github.com/wkumik/RubyFPV-Android-Viewer/actions) (build artifacts) or build from source. - -### Build from source - -``` +```sh git clone https://github.com/wkumik/RubyFPV-Android-Viewer.git cd RubyFPV-Android-Viewer -./gradlew assembleDebug +./gradlew :app:assembleDebug ``` -APK will be at `app/build/outputs/apk/debug/app-debug.apk` +The APK is stamped with its version and lands at: -## Testing without a ground station +``` +app/build/outputs/apk/debug/RubyFPV-v-b-debug.apk +``` -You can test the app using ffmpeg from a PC while the phone is USB-tethered to the PC: +(e.g. `RubyFPV-v1.6-b8-debug.apk`). -``` -ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 \ - -pix_fmt yuv420p -c:v libx264 -profile:v baseline \ - -tune zerolatency -g 30 -bsf:v dump_extra \ - -f h264 udp://:5001?pkt_size=1024 +Install it with adb: + +```sh +adb install -r app/build/outputs/apk/debug/RubyFPV-v1.6-b8-debug.apk ``` -## Roadmap +## Tech notes -See [ROADMAP.md](ROADMAP.md) for planned features including: -- Ruby OSD overlay (V2) -- H.265 support (V3) -- Screen recording / DVR (V4) +- media3 1.6.0 (ExoPlayer + UI + Transformer's `ExperimentalFrameExtractor`) +- jsch (`com.github.mwiede:jsch`) for SSH over the drone's dropbear (exec channels only — + no SFTP) +- Min SDK 24, target / compile SDK 35, Java 17 source/target ## Credits - [RubyFPV](https://rubyfpv.com) by Petru Soroaga -- Video decoding architecture inspired by [OpenIPC Decoder](https://github.com/OpenIPC/decoder) (MIT License) -- NAL parsing based on [Consti10/myMediaCodecPlayer-for-FPV](https://github.com/Consti10/myMediaCodecPlayer-for-FPV) +- Live USB viewer NAL parsing based on + [Consti10/myMediaCodecPlayer-for-FPV](https://github.com/Consti10/myMediaCodecPlayer-for-FPV) ## License diff --git a/app/build.gradle b/app/build.gradle index 8a5bc35..5861d51 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 5 - versionName "1.4" + versionCode 8 + versionName "1.6" } buildFeatures { @@ -46,4 +46,5 @@ dependencies { implementation libs.jsch implementation libs.media3.exoplayer implementation libs.media3.ui + implementation libs.media3.transformer } diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java index 0ceb77e..81a7ec1 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java @@ -3,17 +3,17 @@ import java.io.File; /** - * One onboard clip. Keyed by {@link #stem} (filename without extension) so a - * drone-side {@code .ts} and a locally-remuxed {@code .mp4} describe the same item. + * One onboard clip. Keyed by {@link #stem} (filename without extension) so the + * drone-side {@code .ts} and the downloaded copy describe the same item. */ public class Recording { public enum State { ON_DRONE, // exists on the drone, not yet downloaded DOWNLOADING, // SFTP/exec pull in progress - REMUXING, // .ts -> .mp4 rewrap in progress + REMUXING, // post-download finalise (thumbnail extract); labelled "Preparing…" READY, // local playable file present - FAILED // download or remux error + FAILED // download error } public final String stem; // e.g. rec_00h03m08s_4c3c @@ -27,8 +27,8 @@ public enum State { public int progress; // 0..100 during download public boolean onDrone; // still present on the SD card - public File localFile; // playable file (.mp4 preferred, else .ts) - public long durationMs = -1; // from MediaMetadataRetriever once READY + public File localFile; // playable file (.ts preferred — ExoPlayer plays HEVC-TS) + public long durationMs = -1; // best-effort; raw TS carries no duration header public Recording(String stem) { this.stem = stem; diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java index 773a88b..5d91e11 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -10,11 +10,14 @@ import android.os.Handler; import android.os.Looper; import android.util.TypedValue; +import android.view.Menu; +import android.view.MenuItem; import android.view.View; import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; @@ -144,6 +147,19 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { ui.post(() -> adapter.notifyDataSetChanged()); }); + // Back exits multi-select before leaving the screen. + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (adapter.isSelectionMode()) { + adapter.exitSelection(); + } else { + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + } + }); + loadLocal(); setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); } @@ -196,12 +212,18 @@ private boolean onMenu(android.view.MenuItem item) { } else if (id == R.id.menu_live) { startActivity(new Intent(this, MainActivity.class)); return true; + } else if (id == R.id.menu_return_fpv) { + confirmReturnToFpv(); + return true; } else if (id == R.id.menu_connection) { showConnectionDialog(); return true; } else if (id == R.id.menu_diagnostics) { showDiagnosticsDialog(); return true; + } else if (id == R.id.menu_delete) { + bulkDelete(adapter.selectedItems()); + return true; } return false; } @@ -241,6 +263,44 @@ private void showDiagnosticsDialog() { .show(); } + // ── Return to FPV ─────────────────────────────────────────────────── + + /** + * Reboot the drone out of phone-transfer mode, back into normal FPV. Distinct from + * {@link #disconnect()} (which only drops our SSH session): leaving AP mode requires a + * drone reboot — {@code ap_mode.sh stop} == {@code reboot} — so this is a deliberate, + * confirmed action. The AP (and our link) drop as the drone goes down; that's expected. + */ + private void confirmReturnToFpv() { + if (!connected || shell == null) { + Toast.makeText(this, R.string.return_fpv_need_connection, Toast.LENGTH_SHORT).show(); + return; + } + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.return_fpv_title) + .setMessage(R.string.return_fpv_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.return_fpv_confirm, (d, w) -> doReturnToFpv()) + .show(); + } + + private void doReturnToFpv() { + final RubyShell s = shell; + if (s == null) return; + setStatus(StatusKind.BUSY, getString(R.string.status_return_fpv)); + io.execute(() -> { + try { + s.returnToFpv(); + } catch (Exception ignored) { + // The reboot drops the link mid-command — that's success, not an error. + } + ui.post(() -> { + Toast.makeText(this, R.string.status_return_fpv, Toast.LENGTH_LONG).show(); + disconnect(); // tear down our side; the drone is rebooting into FPV + }); + }); + } + // ── Connection ────────────────────────────────────────────────────── private void connect() { @@ -357,7 +417,7 @@ private void prefetchThumb(Recording r) { try { if (shell == null || Thumbs.CACHE.get(r.stem) != null) return; shell.downloadPrefix(r.tsName, 4 * 1024 * 1024, part); - Thumbs.Meta m = Thumbs.extract(part, r.stem); // bitmap only; duration is partial + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), part, r.stem); // bitmap only if (m.bitmap != null) { Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); ui.post(() -> adapter.update(r)); @@ -382,11 +442,21 @@ private void loadLocal() { boolean mp4 = name.endsWith(".mp4"); boolean ts = name.endsWith(".ts"); if (!mp4 && !ts) continue; + // Purge stray 0-byte .mp4s left by older builds' failed remux attempts + // (the framework muxer creates the output before discovering it can't + // demux HEVC-TS). A 0-byte file would otherwise become the "playable" + // file and ExoPlayer would report container-unsupported. + if (f.length() == 0) { + //noinspection ResultOfMethodCallIgnored + if (mp4) f.delete(); + continue; + } String stem = name.substring(0, name.lastIndexOf('.')); Recording r = byStem.get(stem); if (r == null) { r = new Recording(stem); byStem.put(stem, r); } - // prefer mp4 as the playable file - if (mp4 || r.localFile == null) r.localFile = f; + // Prefer the .ts: ExoPlayer plays HEVC-in-TS directly. Only fall back + // to a (non-empty) .mp4 if no .ts is present. + if (ts || r.localFile == null) r.localFile = f; r.state = Recording.State.READY; r.mtimeEpoch = f.lastModified() / 1000L; } @@ -401,7 +471,7 @@ private void extractMeta(Recording r) { if (Thumbs.CACHE.get(r.stem) != null && r.durationMs > 0) return; final File f = r.localFile; work.execute(() -> { - Thumbs.Meta m = Thumbs.extract(f, r.stem); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), f, r.stem); if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); ui.post(() -> { if (m.durationMs > 0) r.durationMs = m.durationMs; @@ -441,7 +511,7 @@ public void onPrimary(Recording r) { } catch (Exception ignore) { /* OSD sidecar is optional */ } } ui.post(() -> { r.state = Recording.State.REMUXING; adapter.update(r); }); - remux(r, ts); + finishDownload(r, ts); } catch (Exception e) { //noinspection ResultOfMethodCallIgnored ts.delete(); @@ -455,23 +525,18 @@ public void onPrimary(Recording r) { }); } - private void remux(Recording r, File ts) { + /** + * Finalise a freshly-downloaded clip. The {@code .ts} is the playable file: + * ExoPlayer demuxes HEVC-in-TS directly, so there is no remux step (the + * framework muxer can't demux HEVC-TS and would only leave a 0-byte .mp4). + */ + private void finishDownload(Recording r, File ts) { work.execute(() -> { - File mp4 = new File(dir, r.stem + ".mp4"); - boolean ok = Remuxer.remux(ts, mp4); - File playable; - if (ok) { - //noinspection ResultOfMethodCallIgnored - ts.delete(); - playable = mp4; - } else { - playable = ts; // fall back to playing the .ts directly - } - Thumbs.Meta m = Thumbs.extract(playable, r.stem); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), ts, r.stem); if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); ui.post(() -> { - r.localFile = playable; - r.durationMs = m.durationMs; + r.localFile = ts; + if (m.durationMs > 0) r.durationMs = m.durationMs; r.state = Recording.State.READY; adapter.update(r); }); @@ -494,7 +559,7 @@ public void onShare(Recording r) { android.net.Uri uri = FileProvider.getUriForFile( this, getPackageName() + ".fileprovider", r.localFile); Intent send = new Intent(Intent.ACTION_SEND); - send.setType("video/mp4"); + send.setType(mimeFor(r.localFile)); send.putExtra(Intent.EXTRA_STREAM, uri); send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(Intent.createChooser(send, getString(R.string.share_title))); @@ -505,32 +570,144 @@ public void onShare(Recording r) { @Override public void onDelete(Recording r) { - boolean drone = r.onDrone && connected; - String msg = drone - ? getString(R.string.delete_drone_body, r.stem) - : "Remove the downloaded copy of \"" + r.stem + "\"?"; + boolean onPhone = r.isLocal(); + boolean onDrone = r.onDrone && connected; + if (onPhone && onDrone) { + // Let the user pick which copy/copies to remove. + CharSequence[] opts = { + getString(R.string.delete_phone_only), + getString(R.string.delete_sd_only), + getString(R.string.delete_both), + }; + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_title) + .setItems(opts, (d, which) -> + confirmDelete(r, which == 0 || which == 2, which == 1 || which == 2)) + .setNegativeButton(R.string.cancel, null) + .show(); + } else { + confirmDelete(r, onPhone, onDrone); + } + } + + private void confirmDelete(Recording r, boolean phone, boolean drone) { + if (!phone && !drone) return; + String msg; + if (phone && drone) msg = getString(R.string.delete_both_body, r.stem); + else if (drone) msg = getString(R.string.delete_drone_body, r.stem); + else msg = getString(R.string.delete_phone_body, r.stem); new MaterialAlertDialogBuilder(this) .setTitle(R.string.delete_title) .setMessage(msg) .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete_confirm, (d, w) -> doDelete(r, drone)) + .setPositiveButton(R.string.delete_confirm, (d, w) -> { + removeRecording(r, phone, drone); + refreshUi(); + }) + .show(); + } + + // ── Multi-select ──────────────────────────────────────────────────── + + @Override + public void onSelectionChanged(int count) { + if (count > 0) { + toolbar.setNavigationIcon(R.drawable.ic_arrow_back); + toolbar.setNavigationOnClickListener(v -> adapter.exitSelection()); + toolbar.setTitle(getString(R.string.selected_count, count)); + toolbar.setSubtitle(null); + } else { + toolbar.setNavigationIcon(null); + toolbar.setNavigationOnClickListener(null); + toolbar.setTitle(R.string.recordings_title); + toolbar.setSubtitle("v" + com.rubyfpv.viewer.BuildConfig.VERSION_NAME + + " (" + com.rubyfpv.viewer.BuildConfig.VERSION_CODE + ")"); + } + Menu m = toolbar.getMenu(); + if (m != null) { + boolean selecting = count > 0; + int[] normal = { R.id.menu_layout, R.id.menu_refresh, R.id.menu_live, + R.id.menu_connection, R.id.menu_diagnostics }; + for (int id : normal) { + MenuItem it = m.findItem(id); + if (it != null) it.setVisible(!selecting); + } + MenuItem del = m.findItem(R.id.menu_delete); + if (del != null) del.setVisible(selecting); + } + } + + private void bulkDelete(java.util.List sel) { + if (sel == null || sel.isEmpty()) return; + boolean anyPhone = false, anyDrone = false; + for (Recording r : sel) { + if (r.isLocal()) anyPhone = true; + if (r.onDrone && connected) anyDrone = true; + } + if (!anyPhone && !anyDrone) return; + if (anyPhone && anyDrone) { + CharSequence[] opts = { + getString(R.string.delete_phone_only), + getString(R.string.delete_sd_only), + getString(R.string.delete_both), + }; + new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.delete_selected_title, sel.size())) + .setItems(opts, (d, which) -> + confirmBulk(sel, which == 0 || which == 2, which == 1 || which == 2)) + .setNegativeButton(R.string.cancel, null) + .show(); + } else { + confirmBulk(sel, anyPhone, anyDrone); + } + } + + private void confirmBulk(java.util.List sel, boolean phone, boolean drone) { + String where = (phone && drone) ? getString(R.string.delete_where_both) + : drone ? getString(R.string.delete_where_sd) + : getString(R.string.delete_where_phone); + new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.delete_selected_title, sel.size())) + .setMessage(getString(R.string.delete_selected_body, sel.size(), where)) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_confirm, (d, w) -> { + for (Recording r : new ArrayList<>(sel)) { + removeRecording(r, phone && r.isLocal(), + drone && r.onDrone && connected); + } + adapter.exitSelection(); + refreshUi(); + }) .show(); } - private void doDelete(Recording r, boolean drone) { - // local files first - deleteLocal(r); + /** Remove one clip from the chosen location(s); does not touch the UI. */ + private void removeRecording(Recording r, boolean phone, boolean drone) { + if (phone) { + deleteLocal(r); + Thumbs.CACHE.remove(r.stem); + File thumb = new File(thumbsDir, r.stem + ".jpg"); + if (thumb.exists()) //noinspection ResultOfMethodCallIgnored + thumb.delete(); + } if (drone && shell != null) { + final String ts = r.tsName, osd = r.osdName; io.execute(() -> { - try { shell.delete(r.tsName, r.osdName); } catch (Exception ignored) {} + try { shell.delete(ts, osd); } catch (Exception ignored) {} }); + r.onDrone = false; } - Thumbs.CACHE.remove(r.stem); - File thumb = new File(thumbsDir, r.stem + ".jpg"); - if (thumb.exists()) //noinspection ResultOfMethodCallIgnored - thumb.delete(); - byStem.remove(r.stem); - refreshUi(); + if (!r.isLocal() && !r.onDrone) { + byStem.remove(r.stem); + } else if (r.isLocal()) { + r.state = Recording.State.READY; + } else { + r.state = Recording.State.ON_DRONE; + } + } + + private static String mimeFor(File f) { + return f != null && f.getName().endsWith(".ts") ? "video/mp2t" : "video/mp4"; } private void deleteLocal(Recording r) { diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java index e5b5381..5090229 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsAdapter.java @@ -5,18 +5,21 @@ import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; -import android.widget.PopupMenu; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; import com.google.android.material.progressindicator.LinearProgressIndicator; import com.rubyfpv.viewer.R; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class RecordingsAdapter extends RecyclerView.Adapter { @@ -28,11 +31,14 @@ public interface Listener { void onPlay(Recording r); void onShare(Recording r); void onDelete(Recording r); + void onSelectionChanged(int count); // 0 = selection mode off } private final List items = new ArrayList<>(); + private final Set selected = new HashSet<>(); private final Listener listener; private boolean compact; + private boolean selectionMode; public RecordingsAdapter(Listener listener) { this.listener = listener; @@ -42,6 +48,15 @@ public RecordingsAdapter(Listener listener) { public void submit(List next) { items.clear(); items.addAll(next); + // Drop selected stems that no longer exist (e.g. just deleted). + if (!selected.isEmpty()) { + Set present = new HashSet<>(); + for (Recording r : items) present.add(r.stem); + if (selected.retainAll(present) && selected.isEmpty()) { + selectionMode = false; + listener.onSelectionChanged(0); + } + } notifyDataSetChanged(); } @@ -61,6 +76,40 @@ public boolean isCompact() { return compact; } + // ── Multi-select ──────────────────────────────────────────────────── + + public boolean isSelectionMode() { + return selectionMode; + } + + public List selectedItems() { + List out = new ArrayList<>(); + for (Recording r : items) if (selected.contains(r.stem)) out.add(r); + return out; + } + + public void exitSelection() { + if (!selectionMode && selected.isEmpty()) return; + selectionMode = false; + selected.clear(); + notifyDataSetChanged(); + listener.onSelectionChanged(0); + } + + private void enterSelection(Recording r) { + selectionMode = true; + selected.add(r.stem); + notifyDataSetChanged(); + listener.onSelectionChanged(selected.size()); + } + + private void toggle(Recording r) { + if (!selected.remove(r.stem)) selected.add(r.stem); + if (selected.isEmpty()) selectionMode = false; + notifyDataSetChanged(); + listener.onSelectionChanged(selected.size()); + } + @Override public int getItemViewType(int position) { return compact ? TYPE_COMPACT : TYPE_LARGE; @@ -96,8 +145,18 @@ public void onBindViewHolder(@NonNull VH h, int position) { h.badge.setVisibility(View.GONE); } + // Selection visuals. + boolean isSelected = selected.contains(r.stem); + h.selectCheck.setVisibility(isSelected ? View.VISIBLE : View.GONE); + if (h.card != null) { + float density = h.itemView.getResources().getDisplayMetrics().density; + h.card.setStrokeWidth(Math.round((isSelected ? 2f : 1f) * density)); + h.card.setStrokeColor(ContextCompat.getColor(h.card.getContext(), + isSelected ? R.color.ruby : R.color.outline)); + } + boolean ready = r.state == Recording.State.READY; - h.playOverlay.setVisibility(ready ? View.VISIBLE : View.GONE); + h.playOverlay.setVisibility(ready && !selectionMode ? View.VISIBLE : View.GONE); long shownSize = r.remoteSize > 0 ? r.remoteSize : (r.localFile != null ? r.localFile.length() : 0); @@ -127,8 +186,11 @@ public void onBindViewHolder(@NonNull VH h, int position) { break; } - // Primary button only exists in the large layout. - if (h.primary != null) { + // Action row (large layout only) is hidden while selecting. + if (h.actions != null) { + h.actions.setVisibility(selectionMode ? View.GONE : View.VISIBLE); + } + if (h.primary != null && !selectionMode) { boolean busy = r.state == Recording.State.DOWNLOADING || r.state == Recording.State.REMUXING; h.primary.setVisibility(busy ? View.GONE : View.VISIBLE); @@ -150,35 +212,24 @@ public void onBindViewHolder(@NonNull VH h, int position) { } View.OnClickListener tap = v -> { + if (selectionMode) { toggle(r); return; } if (r.state == Recording.State.READY) listener.onPlay(r); else if (r.state == Recording.State.ON_DRONE || r.state == Recording.State.FAILED) listener.onPrimary(r); }; + // Long-press must be wired on every clickable child too: a child with an + // OnClickListener is `clickable` and swallows the touch, so the card's + // own long-press detector never fires when you hold the thumbnail. + View.OnLongClickListener hold = v -> { + if (selectionMode) toggle(r); else enterSelection(r); + return true; + }; h.playOverlay.setOnClickListener(tap); h.thumb.setOnClickListener(tap); - // In compact mode the whole card acts; long-press always offers the full menu. - if (h.primary == null) h.itemView.setOnClickListener(tap); - h.itemView.setOnLongClickListener(v -> { showContextMenu(v, r); return true; }); - } - - private void showContextMenu(View anchor, Recording r) { - boolean ready = r.state == Recording.State.READY; - PopupMenu pm = new PopupMenu(anchor.getContext(), anchor); - pm.getMenuInflater().inflate(R.menu.item_context, pm.getMenu()); - pm.getMenu().findItem(R.id.ctx_play).setVisible(ready); - pm.getMenu().findItem(R.id.ctx_download).setVisible(!ready && r.onDrone); - pm.getMenu().findItem(R.id.ctx_share).setVisible(ready); - pm.getMenu().findItem(R.id.ctx_delete).setVisible(r.onDrone || r.isLocal()); - pm.setOnMenuItemClickListener(item -> { - int id = item.getItemId(); - if (id == R.id.ctx_play) listener.onPlay(r); - else if (id == R.id.ctx_download) listener.onPrimary(r); - else if (id == R.id.ctx_share) listener.onShare(r); - else if (id == R.id.ctx_delete) listener.onDelete(r); - else return false; - return true; - }); - pm.show(); + h.itemView.setOnClickListener(tap); + h.playOverlay.setOnLongClickListener(hold); + h.thumb.setOnLongClickListener(hold); + h.itemView.setOnLongClickListener(hold); } private static String join(String a, String b) { @@ -192,19 +243,24 @@ public int getItemCount() { } static class VH extends RecyclerView.ViewHolder { - final ImageView thumb, playOverlay; + final ImageView thumb, playOverlay, selectCheck; final TextView badge, title, meta; final LinearProgressIndicator progress; - final MaterialButton primary, share, delete; // null in compact layout + final MaterialCardView card; + final View actions; // null in compact layout + final MaterialButton primary, share, delete; // null in compact layout VH(@NonNull View v) { super(v); thumb = v.findViewById(R.id.thumb); playOverlay = v.findViewById(R.id.play_overlay); + selectCheck = v.findViewById(R.id.select_check); badge = v.findViewById(R.id.badge_duration); title = v.findViewById(R.id.title); meta = v.findViewById(R.id.meta); progress = v.findViewById(R.id.progress); + card = (v instanceof MaterialCardView) ? (MaterialCardView) v : null; + actions = v.findViewById(R.id.actions); primary = v.findViewById(R.id.btn_primary); share = v.findViewById(R.id.btn_share); delete = v.findViewById(R.id.btn_delete); diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java deleted file mode 100644 index 60b5159..0000000 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Remuxer.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.rubyfpv.viewer.recordings; - -import android.media.MediaCodec; -import android.media.MediaExtractor; -import android.media.MediaFormat; -import android.media.MediaMuxer; -import android.util.Log; - -import java.io.File; -import java.io.IOException; -import java.nio.ByteBuffer; - -/** - * Lossless rewrap of an MPEG-TS elementary stream into an MP4 container. - * - *

Uses the in-platform {@link MediaExtractor} + {@link MediaMuxer} (API 24+ - * muxes HEVC) to copy samples verbatim — zero re-encode, bit-identical video. - * MP4 gives ExoPlayer/VideoView a real seek index (raw .ts has none) and a - * share-friendly {@code video/mp4} container.

- */ -public final class Remuxer { - - private static final String TAG = "Remuxer"; - - private Remuxer() {} - - /** @return true if the output MP4 was written successfully. */ - public static boolean remux(File src, File dst) { - MediaExtractor extractor = new MediaExtractor(); - MediaMuxer muxer = null; - boolean ok = false; - try { - extractor.setDataSource(src.getAbsolutePath()); - int trackCount = extractor.getTrackCount(); - DebugLog.add(" remux: extractor tracks=" + trackCount); - int[] muxIndex = new int[trackCount]; - int maxInput = 1 << 20; // 1 MB floor - - muxer = new MediaMuxer(dst.getAbsolutePath(), - MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); - - boolean haveVideo = false; - for (int i = 0; i < trackCount; i++) { - muxIndex[i] = -1; - MediaFormat fmt = extractor.getTrackFormat(i); - String mime = fmt.getString(MediaFormat.KEY_MIME); - if (mime == null) continue; - if (mime.startsWith("video/") || mime.startsWith("audio/")) { - extractor.selectTrack(i); - muxIndex[i] = muxer.addTrack(fmt); - if (fmt.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) { - maxInput = Math.max(maxInput, - fmt.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)); - } - if (mime.startsWith("video/")) haveVideo = true; - } - } - if (!haveVideo) { - Log.w(TAG, "No video track in " + src.getName()); - return false; - } - - ByteBuffer buf = ByteBuffer.allocate(maxInput); - MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); - muxer.start(); - - long lastPts = 0; - while (true) { - int track = extractor.getSampleTrackIndex(); - if (track < 0) break; - int size = extractor.readSampleData(buf, 0); - if (size < 0) break; - - long pts = extractor.getSampleTime(); - if (pts < 0) pts = lastPts; - else lastPts = pts; - - info.offset = 0; - info.size = size; - info.presentationTimeUs = pts; - info.flags = (extractor.getSampleFlags() - & MediaExtractor.SAMPLE_FLAG_SYNC) != 0 - ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; - - int dest = muxIndex[track]; - if (dest >= 0) muxer.writeSampleData(dest, buf, info); - extractor.advance(); - } - ok = true; - } catch (IOException | IllegalArgumentException | IllegalStateException e) { - Log.e(TAG, "Remux failed: " + e.getMessage()); - ok = false; - } finally { - try { extractor.release(); } catch (Exception ignored) {} - if (muxer != null) { - try { muxer.stop(); } catch (Exception ignored) {} - try { muxer.release(); } catch (Exception ignored) {} - } - } - if (!ok) { - //noinspection ResultOfMethodCallIgnored - dst.delete(); - } - return ok; - } -} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java index db0b1af..72076f3 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RubyShell.java @@ -180,6 +180,30 @@ public long downloadPrefix(String remoteName, int bytes, File dest) throws Excep return total; } + /** + * Tell the drone to leave phone-transfer mode and reboot back into normal FPV. + * + *

On the drone, {@code /usr/bin/ap_mode.sh stop} simply reboots — that is the + * only reliable way out of the Realtek AP/monitor bounce (the script's own author made + * {@code stop} == {@code reboot}). The reboot tears down this very SSH link mid-command, + * so we background the command and do not wait for output or an exit status: a + * dropped connection right after issuing it is success, not failure. Callers should + * swallow any exception and treat it as "drone is rebooting".

+ */ + public void returnToFpv() throws Exception { + ChannelExec ch = (ChannelExec) session.openChannel("exec"); + // Background it so dropbear hands control back before the box goes down. + ch.setCommand("/usr/bin/ap_mode.sh stop >/dev/null 2>&1 &"); + ch.setInputStream(null); + try { + ch.connect(); + // Give dropbear a moment to receive and start the command before the link drops. + try { Thread.sleep(400); } catch (InterruptedException ignored) {} + } finally { + disconnect(ch); + } + } + /** Delete one or more files from the recordings dir. */ public void delete(String... names) throws Exception { StringBuilder sb = new StringBuilder("rm -f"); diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java index ae38051..c1d0c22 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -1,41 +1,37 @@ package com.rubyfpv.viewer.recordings; +import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; -import android.graphics.ImageFormat; -import android.graphics.Rect; -import android.graphics.YuvImage; -import android.media.Image; -import android.media.MediaCodec; -import android.media.MediaCodecInfo; -import android.media.MediaExtractor; -import android.media.MediaFormat; -import android.media.MediaMetadataRetriever; -import android.util.Log; +import android.net.Uri; import android.util.LruCache; -import java.io.ByteArrayOutputStream; +import androidx.media3.common.Effect; +import androidx.media3.common.MediaItem; +import androidx.media3.transformer.ExperimentalFrameExtractor; + +import com.google.common.util.concurrent.ListenableFuture; + import java.io.File; import java.io.FileOutputStream; -import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.concurrent.TimeUnit; /** - * First-frame thumbnails (DJI-style) + duration. + * First-frame thumbnails (DJI-style). * - *

Android's {@link MediaMetadataRetriever} can't decode MPEG-TS (returns null), - * and the preload path feeds a partial {@code .ts} prefix. So {@link #extract} - * tries, in order of reliability:

- *
    - *
  1. MMR directly — works for {@code .mp4};
  2. - *
  3. remux the input to a tiny {@code .mp4} (same {@link Remuxer} that builds the - * playable clips) then MMR — the workhorse for {@code .ts};
  4. - *
  5. a {@link MediaCodec} first-frame decode as a last resort.
  6. - *
+ *

The clips are HEVC in an MPEG-TS container. Android's framework demuxer + * ({@link android.media.MediaExtractor} / {@link android.media.MediaMetadataRetriever}) + * cannot demux HEVC-in-TS — it opens the file but reports zero tracks — so + * every framework path (MMR, remux-to-MP4, MediaCodec) fails. media3's + * {@link ExperimentalFrameExtractor} runs the same ExoPlayer pipeline that plays + * the clips, whose {@code TsExtractor} does support HEVC, so it decodes the + * first frame directly from the {@code .ts} (or a downloaded {@code .ts} prefix).

*/ public final class Thumbs { - private static final String TAG = "Thumbs"; private static final int MAX_W = 640; + private static final long FRAME_TIMEOUT_S = 20; public static final LruCache CACHE; @@ -53,79 +49,46 @@ private Thumbs() {} public static class Meta { public Bitmap bitmap; - public long durationMs = -1; + public long durationMs = -1; // best-effort; raw TS carries no duration header } - public static Meta extract(File file, String stem) { + public static Meta extract(Context ctx, File file, String stem) { Meta m = new Meta(); String id = stem != null ? stem : file.getName(); DebugLog.add("extract " + id + " (" + file.length() + " B)"); - // 1) MMR directly (works for .mp4) - m.bitmap = mmrFrame(file); - m.durationMs = mmrDuration(file); - String how = "mmr"; - DebugLog.add(" mmr-direct: " + (m.bitmap != null ? "OK" : "null")); - - // 2) remux to a small .mp4 then MMR (the .ts / prefix path) - if (m.bitmap == null) { - File tmp = new File(file.getAbsolutePath() + ".thumb.mp4"); - try { - boolean rx = Remuxer.remux(file, tmp); - DebugLog.add(" remux: " + (rx ? ("OK " + tmp.length() + " B") : "FAIL")); - if (rx) { - m.bitmap = mmrFrame(tmp); - if (m.durationMs <= 0) m.durationMs = mmrDuration(tmp); - how = "remux+mmr"; - DebugLog.add(" mmr-on-remux: " + (m.bitmap != null ? "OK" : "null")); - } - } finally { - //noinspection ResultOfMethodCallIgnored - tmp.delete(); - } - } - - // 3) MediaCodec first-frame decode - if (m.bitmap == null) { - m.bitmap = codecFrame(file); - how = "codec"; - DebugLog.add(" codec: " + (m.bitmap != null ? "OK" : "null")); - } + m.bitmap = frame(ctx, file); + DebugLog.add(" media3-frame: " + (m.bitmap != null ? "OK" : "null")); if (m.bitmap != null) { m.bitmap = scale(m.bitmap); if (stem != null) CACHE.put(stem, m.bitmap); - DebugLog.add(" => thumb OK via " + how + " for " + id); + DebugLog.add(" => thumb OK for " + id); } else { - DebugLog.add(" => thumb FAILED (all paths) for " + id); + DebugLog.add(" => thumb FAILED for " + id); } return m; } - // ── MediaMetadataRetriever ────────────────────────────────────────── - - private static Bitmap mmrFrame(File file) { - MediaMetadataRetriever r = new MediaMetadataRetriever(); + /** + * Decode the first frame via media3's ExoPlayer pipeline. Safe to call from a + * plain background thread (the extractor runs its own internal playback looper); + * blocks until the frame is ready or {@link #FRAME_TIMEOUT_S} elapses. + */ + private static Bitmap frame(Context ctx, File file) { + ExperimentalFrameExtractor fe = new ExperimentalFrameExtractor( + ctx.getApplicationContext(), + new ExperimentalFrameExtractor.Configuration.Builder().build()); try { - r.setDataSource(file.getAbsolutePath()); - return r.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC); + fe.setMediaItem(MediaItem.fromUri(Uri.fromFile(file)), + Collections.emptyList()); + ListenableFuture future = fe.getFrame(0); + ExperimentalFrameExtractor.Frame f = future.get(FRAME_TIMEOUT_S, TimeUnit.SECONDS); + return f != null ? f.bitmap : null; } catch (Exception e) { return null; } finally { - try { r.release(); } catch (Exception ignored) {} - } - } - - private static long mmrDuration(File file) { - MediaMetadataRetriever r = new MediaMetadataRetriever(); - try { - r.setDataSource(file.getAbsolutePath()); - String d = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); - return d == null ? -1 : Long.parseLong(d); - } catch (Exception e) { - return -1; - } finally { - try { r.release(); } catch (Exception ignored) {} + try { fe.release(); } catch (Exception ignored) {} } } @@ -155,113 +118,7 @@ public static void loadDiskInto(File thumbsDir) { } } - // ── MediaCodec first-frame decode (fallback) ──────────────────────── - - private static Bitmap codecFrame(File file) { - MediaExtractor ex = new MediaExtractor(); - MediaCodec codec = null; - try { - ex.setDataSource(file.getAbsolutePath()); - int track = -1; - MediaFormat fmt = null; - for (int i = 0; i < ex.getTrackCount(); i++) { - MediaFormat f = ex.getTrackFormat(i); - String mime = f.getString(MediaFormat.KEY_MIME); - if (mime != null && mime.startsWith("video/")) { track = i; fmt = f; break; } - } - if (track < 0) return null; - ex.selectTrack(track); - - String mime = fmt.getString(MediaFormat.KEY_MIME); - fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, - MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible); - codec = MediaCodec.createDecoderByType(mime); - codec.configure(fmt, null, null, 0); - codec.start(); - - MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); - boolean inputDone = false; - int guard = 0; - while (guard++ < 3000) { - if (!inputDone) { - int inIdx = codec.dequeueInputBuffer(10000); - if (inIdx >= 0) { - ByteBuffer ib = codec.getInputBuffer(inIdx); - int size = ib == null ? -1 : ex.readSampleData(ib, 0); - if (size < 0) { - codec.queueInputBuffer(inIdx, 0, 0, 0, - MediaCodec.BUFFER_FLAG_END_OF_STREAM); - inputDone = true; - } else { - codec.queueInputBuffer(inIdx, 0, size, ex.getSampleTime(), 0); - ex.advance(); - } - } - } - int outIdx = codec.dequeueOutputBuffer(info, 10000); - if (outIdx >= 0) { - Bitmap bmp = null; - if (info.size > 0) { - Image img = codec.getOutputImage(outIdx); - if (img != null) { bmp = imageToBitmap(img); img.close(); } - } - codec.releaseOutputBuffer(outIdx, false); - if (bmp != null) return bmp; - if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) return null; - } - } - return null; - } catch (Exception e) { - return null; - } finally { - if (codec != null) { - try { codec.stop(); } catch (Exception ignored) {} - try { codec.release(); } catch (Exception ignored) {} - } - try { ex.release(); } catch (Exception ignored) {} - } - } - - private static Bitmap imageToBitmap(Image image) { - int w = image.getWidth(); - int h = image.getHeight(); - byte[] nv21 = yuv420ToNv21(image, w, h); - YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, w, h, null); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - yuv.compressToJpeg(new Rect(0, 0, w, h), 90, out); - byte[] jpeg = out.toByteArray(); - return BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); - } - - private static byte[] yuv420ToNv21(Image image, int width, int height) { - Image.Plane[] planes = image.getPlanes(); - byte[] nv21 = new byte[width * height * 3 / 2]; - - ByteBuffer yBuf = planes[0].getBuffer(); - int yRowStride = planes[0].getRowStride(); - int pos = 0; - for (int row = 0; row < height; row++) { - yBuf.position(row * yRowStride); - yBuf.get(nv21, pos, width); - pos += width; - } - - ByteBuffer uBuf = planes[1].getBuffer(); - ByteBuffer vBuf = planes[2].getBuffer(); - int uRowStride = planes[1].getRowStride(); - int uPixStride = planes[1].getPixelStride(); - int vRowStride = planes[2].getRowStride(); - int vPixStride = planes[2].getPixelStride(); - int cw = width / 2; - int ch = height / 2; - for (int row = 0; row < ch; row++) { - for (int col = 0; col < cw; col++) { - nv21[pos++] = vBuf.get(row * vRowStride + col * vPixStride); - nv21[pos++] = uBuf.get(row * uRowStride + col * uPixStride); - } - } - return nv21; - } + // ── Scaling ───────────────────────────────────────────────────────── private static Bitmap scale(Bitmap src) { int w = src.getWidth(); diff --git a/app/src/main/res/layout/item_recording.xml b/app/src/main/res/layout/item_recording.xml index b84d293..ebc2f3c 100644 --- a/app/src/main/res/layout/item_recording.xml +++ b/app/src/main/res/layout/item_recording.xml @@ -43,6 +43,20 @@ app:layout_constraintStart_toStartOf="@id/thumb" app:layout_constraintEnd_toEndOf="@id/thumb" /> + + + + + + + + + Connecting…
Connected to %1$s Connection failed + Drone returning to FPV mode… + + Return to FPV mode + Return to FPV mode? + This reboots the drone out of phone-transfer mode and back into normal flight/FPV mode. The Wi-Fi hotspot will disconnect and the drone will be ready to fly again in about 45 seconds. + Reboot to FPV + Connect to the drone first. Connect to your drone Put the drone in phone-transfer mode, join its Wi-Fi, then connect to browse onboard recordings. @@ -43,9 +50,22 @@ Delete recording? Delete \"%1$s\" from the drone SD card? The downloaded copy on this phone is kept. + Remove the downloaded copy of \"%1$s\" from this phone? The recording on the drone SD card is kept. + Delete \"%1$s\" from both this phone and the drone SD card? This cannot be undone. + Delete from phone + Delete from SD card + Delete from both Remove local copy Delete + Delete selected + %1$d selected + Delete %1$d recordings? + Delete %1$d recordings from the %2$s? This cannot be undone. + phone + drone SD card + phone and drone SD card + Share recording No video player available Could not share file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0df197..5ed28b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ material = "1.12.0" constraintlayout = "2.1.4" swiperefresh = "1.1.0" jsch = "0.2.23" -media3 = "1.4.1" +media3 = "1.6.0" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -16,3 +16,4 @@ swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefre jsch = { group = "com.github.mwiede", name = "jsch", version.ref = "jsch" } media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" } From 35c2e485bcb447399f2277666d8321ae9581f76b Mon Sep 17 00:00:00 2001 From: wkumik Date: Mon, 15 Jun 2026 15:11:39 +0200 Subject: [PATCH 7/7] =?UTF-8?q?v2.0:=20.ts=E2=86=92.mp4=20gallery=20export?= =?UTF-8?q?=20+=20recordings=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to v2.0 (versionCode 10). Ships the onboard-recording workflow: - Browse/download onboard .ts clips over the air unit's phone-transfer Wi-Fi AP - Thumbnails + in-app playback of H.265 .ts - One-tap .ts→.mp4 export into Movies/RubyFPV via media3 InAppMp4Muxer - Multi-select delete, Return-to-FPV Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 8 + .../viewer/player/PlaybackActivity.java | 29 +- .../viewer/recordings/GalleryStore.java | 227 ++++++++++++++++ .../rubyfpv/viewer/recordings/Mp4Export.java | 114 ++++++++ .../rubyfpv/viewer/recordings/Recording.java | 7 +- .../viewer/recordings/RecordingsActivity.java | 257 +++++++++++++++--- .../com/rubyfpv/viewer/recordings/Thumbs.java | 24 +- app/src/main/res/values/strings.xml | 2 +- 9 files changed, 620 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java create mode 100644 app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java diff --git a/app/build.gradle b/app/build.gradle index 5861d51..630819f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.rubyfpv.viewer" minSdk 24 targetSdk 35 - versionCode 8 - versionName "1.6" + versionCode 10 + versionName "2.0" } buildFeatures { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 867481b..eacfc1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,14 @@ + + + + + + finish()); toolbar.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.menu_share) { share(); return true; } @@ -72,7 +82,7 @@ public void onPlayerError(PlaybackException error) { "Playback error: " + error.getErrorCodeName(), Toast.LENGTH_LONG).show(); } }); - player.setMediaItem(MediaItem.fromUri(Uri.fromFile(file))); + player.setMediaItem(MediaItem.fromUri(mediaUri)); player.prepare(); player.setPlayWhenReady(true); } @@ -98,8 +108,13 @@ protected void onStop() { private void share() { try { - Uri uri = FileProvider.getUriForFile( - this, getPackageName() + ".fileprovider", file); + Uri uri; + if (mediaUri != null && "content".equals(mediaUri.getScheme())) { + uri = mediaUri; // gallery item — share directly with a read grant + } else { + uri = FileProvider.getUriForFile( + this, getPackageName() + ".fileprovider", file); + } Intent send = new Intent(Intent.ACTION_SEND); send.setType("video/mp4"); send.putExtra(Intent.EXTRA_STREAM, uri); diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java b/app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java new file mode 100644 index 0000000..46448df --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/GalleryStore.java @@ -0,0 +1,227 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Wraps {@link MediaStore} for the {@code Movies/RubyFPV} video collection — the + * single store of record for downloaded+converted clips. + * + *

On API 29+ files live in the public {@code Movies/RubyFPV} relative path, + * owned by this app (so we may delete them without a {@code RecoverableSecurity- + * Exception}). They appear in Gallery/Photos and survive an app uninstall. On + * pre-Q we write into the public {@code Movies/RubyFPV} dir and register a + * MediaStore row.

+ */ +public final class GalleryStore { + + /** Public sub-album under Movies. */ + private static final String SUBDIR = "RubyFPV"; + /** Q+ RELATIVE_PATH value, e.g. "Movies/RubyFPV". */ + private static final String REL_PATH = Environment.DIRECTORY_MOVIES + "/" + SUBDIR; + + private GalleryStore() {} + + /** A clip discovered in the gallery collection. */ + public static final class GalleryItem { + public Uri uri; + public String displayName; + public String stem; + public long sizeBytes; + public long mtimeEpochSec; + public long durationMs = -1; + } + + private static Uri videoCollection() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + } + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } + + private static String ensureMp4(String displayName) { + if (displayName == null || displayName.isEmpty()) return "clip.mp4"; + return displayName.endsWith(".mp4") ? displayName : displayName + ".mp4"; + } + + /** + * Publish {@code mp4}'s bytes into {@code Movies/RubyFPV} as {@code displayName}. + * + * @return the published content {@link Uri}, or {@code null} on failure. + */ + public static Uri publish(Context ctx, File mp4, String displayName) { + if (mp4 == null || !mp4.exists() || mp4.length() == 0) { + DebugLog.add("gallery publish: source missing/empty " + mp4); + return null; + } + final String name = ensureMp4(displayName); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return publishQ(ctx, mp4, name); + } + return publishLegacy(ctx, mp4, name); + } + + private static Uri publishQ(Context ctx, File mp4, String name) { + final ContentResolver cr = ctx.getContentResolver(); + final Uri collection = videoCollection(); + + ContentValues cv = new ContentValues(); + cv.put(MediaStore.Video.Media.DISPLAY_NAME, name); + cv.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); + cv.put(MediaStore.Video.Media.RELATIVE_PATH, REL_PATH); + cv.put(MediaStore.Video.Media.IS_PENDING, 1); + + Uri uri = null; + try { + uri = cr.insert(collection, cv); + if (uri == null) { + DebugLog.add("gallery publish: insert returned null for " + name); + return null; + } + try (OutputStream out = cr.openOutputStream(uri); + InputStream in = new FileInputStream(mp4)) { + if (out == null) throw new java.io.IOException("openOutputStream null"); + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + out.flush(); + } + ContentValues clear = new ContentValues(); + clear.put(MediaStore.Video.Media.IS_PENDING, 0); + cr.update(uri, clear, null, null); + DebugLog.add("gallery publish OK: " + name + " -> " + uri); + return uri; + } catch (Exception e) { + DebugLog.add("gallery publish FAILED (" + name + "): " + e.getMessage()); + if (uri != null) { + try { cr.delete(uri, null, null); } catch (Exception ignored) {} + } + return null; + } + } + + private static Uri publishLegacy(Context ctx, File mp4, String name) { + try { + File baseDir = new File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), + SUBDIR); + //noinspection ResultOfMethodCallIgnored + baseDir.mkdirs(); + File out = new File(baseDir, name); + try (InputStream in = new FileInputStream(mp4); + OutputStream os = new java.io.FileOutputStream(out)) { + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) os.write(buf, 0, n); + os.flush(); + } + ContentValues cv = new ContentValues(); + cv.put(MediaStore.Video.Media.DISPLAY_NAME, name); + cv.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); + cv.put(MediaStore.Video.Media.DATA, out.getAbsolutePath()); + Uri uri = ctx.getContentResolver() + .insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, cv); + DebugLog.add("gallery publish (legacy) " + + (uri != null ? "OK" : "row null") + ": " + out.getAbsolutePath()); + return uri; + } catch (Exception e) { + DebugLog.add("gallery publish (legacy) FAILED (" + name + "): " + e.getMessage()); + return null; + } + } + + /** List clips under {@code Movies/RubyFPV}. Newest items are not guaranteed first. */ + public static List query(Context ctx) { + List out = new ArrayList<>(); + final Uri collection = videoCollection(); + final boolean q = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + + String[] projection = q + ? new String[]{ + MediaStore.Video.Media._ID, + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.SIZE, + MediaStore.Video.Media.DATE_MODIFIED, + MediaStore.Video.Media.DURATION, + } + : new String[]{ + MediaStore.Video.Media._ID, + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.SIZE, + MediaStore.Video.Media.DATE_MODIFIED, + MediaStore.Video.Media.DURATION, + MediaStore.Video.Media.DATA, + }; + + String selection; + String[] args; + if (q) { + selection = MediaStore.Video.Media.RELATIVE_PATH + " LIKE ?"; + args = new String[]{ REL_PATH + "%" }; + } else { + selection = MediaStore.Video.Media.DATA + " LIKE ?"; + args = new String[]{ "%/" + REL_PATH + "/%" }; + } + + try (Cursor c = ctx.getContentResolver().query( + collection, projection, selection, args, + MediaStore.Video.Media.DATE_MODIFIED + " DESC")) { + if (c == null) return out; + int idCol = c.getColumnIndexOrThrow(MediaStore.Video.Media._ID); + int nameCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME); + int sizeCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE); + int dateCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED); + int durCol = c.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION); + while (c.moveToNext()) { + GalleryItem g = new GalleryItem(); + long id = c.getLong(idCol); + g.uri = ContentUris.withAppendedId(collection, id); + g.displayName = c.getString(nameCol); + g.stem = stemOf(g.displayName); + g.sizeBytes = c.getLong(sizeCol); + g.mtimeEpochSec = c.getLong(dateCol); + long dur = c.isNull(durCol) ? -1 : c.getLong(durCol); + g.durationMs = dur > 0 ? dur : -1; + out.add(g); + } + DebugLog.add("gallery query: " + out.size() + " item(s)"); + } catch (Exception e) { + DebugLog.add("gallery query FAILED: " + e.getMessage()); + } + return out; + } + + /** Delete a clip we created. App owns it on Q+, so no recoverable-security prompt. */ + public static boolean delete(Context ctx, Uri uri) { + if (uri == null) return false; + try { + int n = ctx.getContentResolver().delete(uri, null, null); + DebugLog.add("gallery delete " + (n > 0 ? "OK" : "no-op") + ": " + uri); + return n > 0; + } catch (Exception e) { + DebugLog.add("gallery delete FAILED (" + uri + "): " + e.getMessage()); + return false; + } + } + + private static String stemOf(String displayName) { + if (displayName == null) return ""; + return displayName.endsWith(".mp4") + ? displayName.substring(0, displayName.length() - 4) + : displayName; + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java new file mode 100644 index 0000000..4531e04 --- /dev/null +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Mp4Export.java @@ -0,0 +1,114 @@ +package com.rubyfpv.viewer.recordings; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.media3.common.MediaItem; +import androidx.media3.transformer.Composition; +import androidx.media3.transformer.ExportException; +import androidx.media3.transformer.ExportResult; +import androidx.media3.transformer.InAppMp4Muxer; +import androidx.media3.transformer.Transformer; + +import java.io.File; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Remux an onboard HEVC clip from its MPEG-TS container into MP4 so it can be + * shared to apps that reject {@code .ts} (Instagram, WhatsApp, TikTok, …). + * + *

The Android framework muxer ({@link android.media.MediaMuxer}) can't demux + * HEVC-in-TS — it reports zero tracks (see {@link Thumbs}) — so we use media3's + * {@link Transformer}, which drives the same ExoPlayer {@code TsExtractor} that + * plays and thumbnails the clip. With no effects applied and a codec the MP4 + * container supports (HEVC), Transformer transmuxes (stream-copy): no + * decode/encode, so it's fast and lossless — only the container changes.

+ * + *

{@link #remux} blocks the calling (background) thread until the export + * finishes or times out. Transformer must be created and driven from a single + * {@link android.os.Looper}, so we host it on a private {@link HandlerThread} + * and bridge completion back with a {@link CountDownLatch}. The MP4 is written + * to a {@code .part} sibling and renamed into place only on success, so a + * crash/kill mid-convert never leaves a half-written {@code .mp4} that later + * looks playable.

+ */ +public final class Mp4Export { + + /** Generous ceiling; a transmux is I/O-bound, not CPU, so this is rarely hit. */ + private static final long TIMEOUT_MIN = 10; + + private Mp4Export() {} + + /** + * Remux {@code src} (.ts) into {@code dst} (.mp4), stream-copying the HEVC. + * + * @return {@code true} if {@code dst} now holds a complete, playable MP4. On + * failure {@code dst} is left absent and the caller should keep + * {@code src} as the (still .ts) playable/shareable file. + */ + public static boolean remux(Context ctx, File src, File dst) { + final Context app = ctx.getApplicationContext(); + final File part = new File(dst.getAbsolutePath() + ".part"); + //noinspection ResultOfMethodCallIgnored + part.delete(); // clear any leftover from a previous interrupted run + + final CountDownLatch done = new CountDownLatch(1); + final AtomicBoolean ok = new AtomicBoolean(false); + final Transformer[] ref = new Transformer[1]; + + final HandlerThread ht = new HandlerThread("mp4-export"); + ht.start(); + final Handler h = new Handler(ht.getLooper()); + + h.post(() -> { + Transformer t = new Transformer.Builder(app) + // Use media3's in-app MP4 muxer, NOT the Android framework MediaMuxer: + // the framework muxer fails to write this onboard HEVC stream + // (stop() error -1007 ERROR_MALFORMED). The in-app muxer handles it. + .setMuxerFactory(new InAppMp4Muxer.Factory()) + .addListener(new Transformer.Listener() { + @Override + public void onCompleted(Composition c, ExportResult r) { + ok.set(true); + done.countDown(); + } + + @Override + public void onError(Composition c, ExportResult r, ExportException e) { + DebugLog.add("mp4 export failed (" + src.getName() + "): " + e.getMessage()); + done.countDown(); + } + }) + .build(); + ref[0] = t; + t.start(MediaItem.fromUri(Uri.fromFile(src)), part.getAbsolutePath()); + }); + + boolean finished = false; + try { + finished = done.await(TIMEOUT_MIN, TimeUnit.MINUTES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (!finished) { + DebugLog.add("mp4 export timed out for " + src.getName()); + // cancel() must run on the Transformer's own looper; quitSafely() lets + // this queued message run before the looper stops. + h.post(() -> { try { if (ref[0] != null) ref[0].cancel(); } catch (Exception ignore) {} }); + } + ht.quitSafely(); + + boolean good = ok.get() && part.exists() && part.length() > 0 && part.renameTo(dst); + if (!good) { + //noinspection ResultOfMethodCallIgnored + part.delete(); + //noinspection ResultOfMethodCallIgnored + dst.delete(); + } + return good; + } +} diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java index 81a7ec1..2a3838b 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Recording.java @@ -11,7 +11,7 @@ public class Recording { public enum State { ON_DRONE, // exists on the drone, not yet downloaded DOWNLOADING, // SFTP/exec pull in progress - REMUXING, // post-download finalise (thumbnail extract); labelled "Preparing…" + REMUXING, // post-download: remux .ts→.mp4 + thumbnail; labelled "Converting to MP4…" READY, // local playable file present FAILED // download error } @@ -27,7 +27,8 @@ public enum State { public int progress; // 0..100 during download public boolean onDrone; // still present on the SD card - public File localFile; // playable file (.ts preferred — ExoPlayer plays HEVC-TS) + public android.net.Uri localUri; // published gallery item (Movies/RubyFPV) — store of record; null until published + public File localFile; // transient/fallback only: staged .mp4 (publish failed) or .ts (remux failed) public long durationMs = -1; // best-effort; raw TS carries no duration header public Recording(String stem) { @@ -35,6 +36,6 @@ public Recording(String stem) { } public boolean isLocal() { - return localFile != null && localFile.exists(); + return localUri != null || (localFile != null && localFile.exists()); } } diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java index 5d91e11..41b59be 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/RecordingsActivity.java @@ -5,7 +5,10 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.content.res.ColorStateList; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -18,6 +21,8 @@ import android.widget.Toast; import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; @@ -88,6 +93,14 @@ public class RecordingsActivity extends AppCompatActivity private File thumbsDir; private File cacheDir; + /** Read-media permission so the gallery scan can see clips a PRIOR install created. */ + private final ActivityResultLauncher readMediaPerm = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { + DebugLog.add("read-media permission " + (granted ? "granted" : "denied")); + // Items the current install created are queryable regardless; rescan either way. + loadLocal(); + }); + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -161,9 +174,26 @@ public void handleOnBackPressed() { }); loadLocal(); + requestReadMediaIfNeeded(); setStatus(StatusKind.OFFLINE, getString(R.string.status_disconnected)); } + /** + * Request the read-media permission (non-blocking) so the gallery scan can list + * clips created by a PRIOR install. If denied we still proceed: clips this install + * created remain queryable without the permission. + */ + private void requestReadMediaIfNeeded() { + final String perm = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + ? android.Manifest.permission.READ_MEDIA_VIDEO + : android.Manifest.permission.READ_EXTERNAL_STORAGE; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && ContextCompat.checkSelfPermission(this, perm) + != PackageManager.PERMISSION_GRANTED) { + readMediaPerm.launch(perm); + } + } + /** Pad the app bar for the status bar and the list for the nav bar (edge-to-edge). */ private void applyInsets() { View appbar = findViewById(R.id.appbar); @@ -435,43 +465,124 @@ private void prefetchThumb(Recording r) { // ── Local scan ────────────────────────────────────────────────────── private void loadLocal() { + // 1) The gallery (Movies/RubyFPV) is the store of record — scan it first. + List items = GalleryStore.query(getApplicationContext()); + for (GalleryStore.GalleryItem g : items) { + if (g.stem == null || g.stem.isEmpty()) continue; + Recording r = byStem.get(g.stem); + if (r == null) { r = new Recording(g.stem); byStem.put(g.stem, r); } + r.localUri = g.uri; + r.localFile = null; + if (g.durationMs > 0) r.durationMs = g.durationMs; + if (g.mtimeEpochSec > 0) r.mtimeEpoch = g.mtimeEpochSec; + r.state = Recording.State.READY; + } + for (Recording r : byStem.values()) { + if (r.state == Recording.State.READY && r.localUri != null) extractMeta(r); + } + refreshUi(); + + // 2) Best-effort migration of any leftover private files (older builds / + // interrupted publish). Normally zero on this device; never fatal. + migratePrivateLeftovers(); + } + + /** + * Move any clips still sitting in the app-private {@code dir} (from older builds + * or a publish interrupted mid-write) into the gallery, then delete the private + * copy. Purges {@code .part} and 0-byte leftovers. Runs scans on {@link #work}. + */ + private void migratePrivateLeftovers() { File[] files = dir.listFiles(); if (files == null) return; for (File f : files) { String name = f.getName(); + if (name.endsWith(".part")) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + continue; + } boolean mp4 = name.endsWith(".mp4"); boolean ts = name.endsWith(".ts"); if (!mp4 && !ts) continue; - // Purge stray 0-byte .mp4s left by older builds' failed remux attempts - // (the framework muxer creates the output before discovering it can't - // demux HEVC-TS). A 0-byte file would otherwise become the "playable" - // file and ExoPlayer would report container-unsupported. if (f.length() == 0) { //noinspection ResultOfMethodCallIgnored - if (mp4) f.delete(); + f.delete(); continue; } String stem = name.substring(0, name.lastIndexOf('.')); - Recording r = byStem.get(stem); + Recording existing = byStem.get(stem); + // Already published to the gallery? Drop the private dup. + if (existing != null && existing.localUri != null) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + continue; + } + Recording r = existing; if (r == null) { r = new Recording(stem); byStem.put(stem, r); } - // Prefer the .ts: ExoPlayer plays HEVC-in-TS directly. Only fall back - // to a (non-empty) .mp4 if no .ts is present. - if (ts || r.localFile == null) r.localFile = f; - r.state = Recording.State.READY; - r.mtimeEpoch = f.lastModified() / 1000L; - } - // lazily extract thumbnails/durations for ready clips - for (Recording r : byStem.values()) { - if (r.state == Recording.State.READY && r.localFile != null) extractMeta(r); + // Prefer migrating the .mp4 if both exist; the .ts is handled below. + if (mp4) { + r.localFile = f; + r.state = Recording.State.REMUXING; + final Recording fr = r; + final File ff = f; + ui.post(() -> adapter.update(fr)); + work.execute(() -> publishAndFinalize(fr, ff)); + } else { // .ts and no sibling .mp4 being migrated + if (new File(dir, stem + ".mp4").exists()) { + //noinspection ResultOfMethodCallIgnored + f.delete(); // .mp4 sibling will be migrated instead + continue; + } + r.localFile = f; + convertLocal(r); + } } refreshUi(); } + /** + * Publish an already-converted private {@code mp4} into the gallery, delete the + * private copy on success, extract the thumbnail and mark READY. On failure keep + * the private {@code mp4} as the playable/shareable fallback. Runs on {@link #work}. + */ + private void publishAndFinalize(Recording r, File mp4) { + Uri uri = GalleryStore.publish(getApplicationContext(), mp4, r.stem + ".mp4"); + if (uri != null) { + //noinspection ResultOfMethodCallIgnored + mp4.delete(); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), uri, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localUri = uri; + r.localFile = null; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + } else { + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), mp4, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localFile = mp4; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + } + } + private void extractMeta(Recording r) { if (Thumbs.CACHE.get(r.stem) != null && r.durationMs > 0) return; + final Uri uri = r.localUri; final File f = r.localFile; + if (uri == null && f == null) return; work.execute(() -> { - Thumbs.Meta m = Thumbs.extract(getApplicationContext(), f, r.stem); + Thumbs.Meta m = uri != null + ? Thumbs.extract(getApplicationContext(), uri, r.stem) + : Thumbs.extract(getApplicationContext(), f, r.stem); if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); ui.post(() -> { if (m.durationMs > 0) r.durationMs = m.durationMs; @@ -526,40 +637,111 @@ public void onPrimary(Recording r) { } /** - * Finalise a freshly-downloaded clip. The {@code .ts} is the playable file: - * ExoPlayer demuxes HEVC-in-TS directly, so there is no remux step (the - * framework muxer can't demux HEVC-TS and would only leave a 0-byte .mp4). + * Finalise a freshly-downloaded clip: remux the {@code .ts} into a share-ready + * {@code .mp4} (social apps reject MPEG-TS), then extract the thumbnail. The + * caller has already moved the row to {@link Recording.State#REMUXING}. */ private void finishDownload(Recording r, File ts) { - work.execute(() -> { - Thumbs.Meta m = Thumbs.extract(getApplicationContext(), ts, r.stem); - if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); - ui.post(() -> { - r.localFile = ts; - if (m.durationMs > 0) r.durationMs = m.durationMs; - r.state = Recording.State.READY; - adapter.update(r); - }); + work.execute(() -> remuxAndFinalize(r, ts)); + } + + /** + * Upgrade a clip that is still backed locally by a {@code .ts} (downloaded by + * an older build) to a shareable {@code .mp4}, in the background. + */ + private void convertLocal(Recording r) { + final File ts = r.localFile; + ui.post(() -> { r.state = Recording.State.REMUXING; adapter.update(r); }); + work.execute(() -> remuxAndFinalize(r, ts)); + } + + /** + * Remux {@code ts} → a staged {@code stem.mp4} (HEVC stream-copy), then publish it + * into the gallery ({@code Movies/RubyFPV}) as the store of record and delete both + * staged private files. Extracts the thumbnail and marks READY. + * + *

Fallbacks (private file kept, {@code localUri} stays null):

+ *
    + *
  • publish fails → keep staged {@code .mp4} as playable/shareable;
  • + *
  • remux fails → keep the {@code .ts} as playable/shareable.
  • + *
+ * Runs on {@link #work}. + */ + private void remuxAndFinalize(Recording r, File ts) { + File mp4 = new File(dir, r.stem + ".mp4"); + if (Mp4Export.remux(getApplicationContext(), ts, mp4)) { + //noinspection ResultOfMethodCallIgnored + ts.delete(); + Uri uri = GalleryStore.publish(getApplicationContext(), mp4, r.stem + ".mp4"); + if (uri != null) { + //noinspection ResultOfMethodCallIgnored + mp4.delete(); + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), uri, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localUri = uri; + r.localFile = null; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); + }); + return; + } + // Publish failed — keep the staged .mp4 as the fallback. + DebugLog.add("publish returned null, keeping private mp4 for " + r.stem); + finalizeFallback(r, mp4); + return; + } + // Remux failed — keep the .ts as the playable/shareable fallback. + finalizeFallback(r, ts); + } + + /** Mark READY backed by a private file (publish/remux fallback path). */ + private void finalizeFallback(Recording r, File pf) { + Thumbs.Meta m = Thumbs.extract(getApplicationContext(), pf, r.stem); + if (m.bitmap != null) Thumbs.saveDisk(thumbsDir, r.stem, m.bitmap); + final long durMs = m.durationMs; + ui.post(() -> { + r.localFile = pf; + r.localUri = null; + if (durMs > 0) r.durationMs = durMs; + r.state = Recording.State.READY; + adapter.update(r); }); } @Override public void onPlay(Recording r) { - if (r.localFile == null || !r.localFile.exists()) return; Intent i = new Intent(this, PlaybackActivity.class); - i.putExtra(PlaybackActivity.EXTRA_PATH, r.localFile.getAbsolutePath()); + if (r.localUri != null) { + i.putExtra(PlaybackActivity.EXTRA_URI, r.localUri.toString()); + } else if (r.localFile != null && r.localFile.exists()) { + i.putExtra(PlaybackActivity.EXTRA_PATH, r.localFile.getAbsolutePath()); + } else { + return; + } i.putExtra(PlaybackActivity.EXTRA_TITLE, r.stem); startActivity(i); } @Override public void onShare(Recording r) { - if (r.localFile == null || !r.localFile.exists()) return; try { - android.net.Uri uri = FileProvider.getUriForFile( - this, getPackageName() + ".fileprovider", r.localFile); + Uri uri; + String type; + if (r.localUri != null) { + uri = r.localUri; // gallery item — share directly + type = "video/mp4"; + } else if (r.localFile != null && r.localFile.exists()) { + uri = FileProvider.getUriForFile( + this, getPackageName() + ".fileprovider", r.localFile); + type = mimeFor(r.localFile); + } else { + return; + } Intent send = new Intent(Intent.ACTION_SEND); - send.setType(mimeFor(r.localFile)); + send.setType(type); send.putExtra(Intent.EXTRA_STREAM, uri); send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(Intent.createChooser(send, getString(R.string.share_title))); @@ -711,6 +893,13 @@ private static String mimeFor(File f) { } private void deleteLocal(Recording r) { + // Primary store of record: the gallery item. + if (r.localUri != null) { + final Uri uri = r.localUri; + work.execute(() -> GalleryStore.delete(getApplicationContext(), uri)); + r.localUri = null; + } + // Any private staged/fallback files. File[] victims = { r.localFile, new File(dir, r.stem + ".mp4"), diff --git a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java index c1d0c22..ec85d7a 100644 --- a/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java +++ b/app/src/main/java/com/rubyfpv/viewer/recordings/Thumbs.java @@ -53,13 +53,22 @@ public static class Meta { } public static Meta extract(Context ctx, File file, String stem) { - Meta m = new Meta(); String id = stem != null ? stem : file.getName(); DebugLog.add("extract " + id + " (" + file.length() + " B)"); + return finish(frame(ctx, Uri.fromFile(file)), stem, id); + } - m.bitmap = frame(ctx, file); - DebugLog.add(" media3-frame: " + (m.bitmap != null ? "OK" : "null")); + /** {@link #extract(Context, File, String)} but sourced from a {@code content://} Uri. */ + public static Meta extract(Context ctx, Uri uri, String stem) { + String id = stem != null ? stem : String.valueOf(uri); + DebugLog.add("extract " + id + " (uri)"); + return finish(frame(ctx, uri), stem, id); + } + private static Meta finish(Bitmap bmp, String stem, String id) { + Meta m = new Meta(); + m.bitmap = bmp; + DebugLog.add(" media3-frame: " + (m.bitmap != null ? "OK" : "null")); if (m.bitmap != null) { m.bitmap = scale(m.bitmap); if (stem != null) CACHE.put(stem, m.bitmap); @@ -70,17 +79,22 @@ public static Meta extract(Context ctx, File file, String stem) { return m; } + /** Decode the first frame from a file. */ + public static Bitmap frame(Context ctx, File file) { + return frame(ctx, Uri.fromFile(file)); + } + /** * Decode the first frame via media3's ExoPlayer pipeline. Safe to call from a * plain background thread (the extractor runs its own internal playback looper); * blocks until the frame is ready or {@link #FRAME_TIMEOUT_S} elapses. */ - private static Bitmap frame(Context ctx, File file) { + public static Bitmap frame(Context ctx, Uri uri) { ExperimentalFrameExtractor fe = new ExperimentalFrameExtractor( ctx.getApplicationContext(), new ExperimentalFrameExtractor.Configuration.Builder().build()); try { - fe.setMediaItem(MediaItem.fromUri(Uri.fromFile(file)), + fe.setMediaItem(MediaItem.fromUri(uri), Collections.emptyList()); ListenableFuture future = fe.getFrame(0); ExperimentalFrameExtractor.Frame f = future.get(FRAME_TIMEOUT_S, TimeUnit.SECONDS); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d785294..14835b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,7 @@ On drone Downloading… %1$d%% - Preparing… + Converting to MP4… Ready to play Failed