feat(android,ios): play the subtitle files beside an episode

The bridge's open_subtitles opens and publishes them and names the one to
turn on. The native player holds them until mpv has the episode loaded,
adds them with sub-add, and releases them instead when another episode
was loaded in the meantime.
This commit is contained in:
nvrl committed 2026-10-13 21:11:20 +02:00
1 parent f0fe90f0fe
commit 7790ae9cbb
6 files changed
+311 -5

No files matched your search

+89
View File
@@ -27,6 +27,7 @@ extern "C" {
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
namespace {
@@ -212,6 +213,14 @@ struct PlaybackState {
int64_t generation = 0;
};
/// A subtitle file published for mpv, and what to call it.
struct Subtitle {
uint64_t handle = 0;
std::string title;
std::string language;
bool select = false;
};
class Player {
public:
Player() {
@@ -338,7 +347,10 @@ class Player {
state_.generation = generation;
state_.loading = true;
error_.clear();
for (const auto &subtitle : pending_subtitles_) released_.push_back(subtitle.handle);
pending_subtitles_.clear();
}
flush_released();
const std::string url = "pstr://" + std::to_string(handle);
const char *command[] = {"loadfile", url.c_str(), nullptr};
if (mpv_command(mpv_, command) < 0) {
@@ -372,6 +384,27 @@ class Player {
mpv_command(mpv_, command);
}
/// A subtitle file beside the file of load `generation`, added once mpv
/// has that file loaded: an external track belongs to the file open at
/// the time. Handed over for a file already replaced, it is released
/// instead. `select` turns it on, but only when the file has no subtitle
/// of its own playing.
void add_subtitle(int64_t generation, Subtitle subtitle) {
bool now = false;
{
std::lock_guard lock(state_mutex_);
if (generation != state_.generation) {
released_.push_back(subtitle.handle);
} else if (loaded_generation_ == generation) {
now = true;
} else {
pending_subtitles_.push_back(std::move(subtitle));
}
}
flush_released();
if (now) attach_subtitle(subtitle);
}
PlaybackState state() const {
std::lock_guard lock(state_mutex_);
return state_;
@@ -434,6 +467,35 @@ class Player {
}
private:
void attach_subtitle(const Subtitle &subtitle) {
bool showing = false;
if (char *sid = mpv_get_property_string(mpv_, "sid")) {
showing = std::strcmp(sid, "no") != 0;
mpv_free(sid);
}
const std::string url = "pstr://" + std::to_string(subtitle.handle);
std::vector<const char *> command{
"sub-add", url.c_str(), subtitle.select && !showing ? "select" : "auto",
subtitle.title.c_str()};
if (!subtitle.language.empty()) command.push_back(subtitle.language.c_str());
command.push_back(nullptr);
// On failure mpv may never have opened it, so nothing will close it.
// Releasing one it did open and close is a no-op.
if (mpv_command(mpv_, command.data()) < 0) pstr_android_stream_release(subtitle.handle);
}
/// Release the subtitle streams queued under `state_mutex_`, outside it:
/// a release runs Rust destructors, which must not run under a lock a
/// reader of the state may be waiting on.
void flush_released() {
std::vector<uint64_t> handles;
{
std::lock_guard lock(state_mutex_);
handles.swap(released_);
}
for (const uint64_t handle : handles) pstr_android_stream_release(handle);
}
struct MpvFree {
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
};
@@ -534,6 +596,16 @@ class Player {
if (event->event_id == MPV_EVENT_FILE_LOADED) {
const double start = pending_start_.exchange(0.0);
if (start > 0.0) set_double("time-pos", start);
std::vector<Subtitle> ready;
{
std::lock_guard lock(state_mutex_);
// Mid-load, this is the outgoing file finishing its load.
if (!state_.loading) {
loaded_generation_ = state_.generation;
ready.swap(pending_subtitles_);
}
}
for (const auto &subtitle : ready) attach_subtitle(subtitle);
}
if (event->event_id == MPV_EVENT_END_FILE) {
const auto *end = static_cast<mpv_event_end_file *>(event->data);
@@ -757,6 +829,12 @@ class Player {
bool surface_changed_ = false;
std::atomic<bool> frame_ready_{false};
std::atomic<double> pending_start_{0.0};
/// The load generation whose file mpv has loaded, under `state_mutex_`.
int64_t loaded_generation_ = -1;
/// Subtitles handed over before their file loaded, under `state_mutex_`.
std::vector<Subtitle> pending_subtitles_;
/// Streams to release once `state_mutex_` is let go.
std::vector<uint64_t> released_;
};
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
@@ -850,6 +928,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
: JNI_FALSE);
});
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAddSubtitle(JNIEnv *env, jobject, jlong handle, jlong generation, jlong stream, jstring title, jstring language, jboolean select) noexcept {
c_boundary([=] {
Subtitle subtitle{static_cast<uint64_t>(stream), {}, {}, select == JNI_TRUE};
auto *p = from(handle);
if (!p || !utf8(env, title, subtitle.title) || !utf8(env, language, subtitle.language)) {
pstr_android_stream_release(subtitle.handle);
return;
}
p->add_subtitle(generation, std::move(subtitle));
});
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
}
@@ -19,6 +19,7 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.chapterPlan
/**
@@ -292,6 +293,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
/**
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
*
* Native code holds them until mpv has the file loaded, since an external
* track belongs to the file open at the time, and releases them instead
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
* is turned on only when the file has no subtitle of its own playing;
* pass [subtitles] false to turn none on.
*/
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
val generation = currentGeneration.toLong()
val added = withOpenHandle { handle ->
files.files.forEachIndexed { index, file ->
if (currentMedia == media) {
val select = subtitles && files.preferred == index.toUInt()
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
} else {
pstrAndroidStreamRelease(file.handle.toLong())
}
}
}
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
}
private fun startPolling() {
if (poller?.isActive == true) return
// Off the main thread deliberately: `nativeState` and `nativeTracks`
@@ -414,6 +439,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
subtitles: Boolean,
hardwareDecoding: Boolean,
): Boolean
private external fun nativeAddSubtitle(
handle: Long,
generation: Long,
stream: Long,
title: String,
language: String?,
select: Boolean,
)
private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double)
@@ -250,11 +250,19 @@ fun PlayerScreen(
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
SettingsStore(context).hardwareDecoding,
)
// After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume)
nativeHost.setMuted(startup.muted)
nativeHost.setSpeed(startup.speed)
session.transferToPlayer()
// While mpv opens the episode, and off the spinner's path: the host
// holds them until it has the file.
launch(Dispatchers.IO) {
val files = NativeRuntime.engine()
.openSubtitles(episode.shareId, episode.linkId, startup.subtitleLanguage)
nativeHost.addSubtitles(key, files, startup.subtitles)
}
}.onFailure { playbackError = it.message ?: "Playback failed" }
opening = false
}