Compare commits

17 Commits
Author SHA1 Message Date
nvrl ab7f057afe chore(release): bump version to 1.6.0 2026-10-14 22:12:58 +02:00
nvrl b1ea076ea3 feat(core,app,android,ios): play the dialogue, not the signs and songs
Anime releases often carry two subtitle tracks in one language, the
dialogue and a "Signs & Songs" track, and the second is as often as not
the one marked default or forced. mpv, picking by language, played it.

pstr_core::subtitles reads a track's name and forced flag for whether it
is the dialogue, and every client asks it once per file, after mpv has
made its own pick, whether to switch to a track in the same language
that is. A subtitle track the viewer chose by name stays, and subtitles
that are off stay off.
2026-10-14 18:18:40 +02:00
nvrl 8d615579e2 fix(ios): fit the picture to the window after a rotation
MPVKit's MoltenVK context reads the layer's drawableSize only when mpv
builds its video output and reports no resize after that (mpvkit/MPVKit#3).
A player opened upright and turned on its side went on drawing the upright
frame into a corner of the landscape drawable. The view now tells the
player when its drawable changes, and once the size settles the player
rebuilds mpv's output by turning the picture off and on, as the background
already does. The vid writes run in order off the main thread.
2026-10-14 18:01:22 +02:00
nvrl cafd969567 fix(app): build the fontconfig face helper only where it is used
Windows and macOS find their CJK font by path, so on those targets the
helper was dead code and the Windows cross gate refused it.
2026-10-14 00:07:46 +02:00
nvrl 69981e3b45 chore(release): bump version to 1.5.1 2026-10-13 23:29:57 +02:00
nvrl cbe0f7166b fix(app): never let the system's CJK font stop the app at start
fc-match reports a variable font's named instance in the high 16 bits of
%{index}. Fedora's variable Noto Sans CJK answered 262144, egui read that
as a face index, and panicked on the font it could not parse. Keep only
the face, and parse the fallback with skrifa first so a font egui cannot
read is left out instead.

Fixes #1
2026-10-13 21:47:45 +02:00
nvrl f0402e74f9 feat(android): install on Android TV, and take a remote's keys in the player
The app is listed in the TV launcher with a banner, and neither a touch
screen nor leanback is required. While the controls are down the player
takes D-pad left and right as a ten-second seek, the middle button as
bringing the controls up, and play/pause and space as they say.
2026-10-13 21:37:40 +02:00
nvrl d4e9628199 feat(ios): notify when a show gains episodes
A BGProcessingTask runs the same crawl_for_news no sooner than every six
hours, when iOS chooses. A processing task rather than an app refresh,
since a crawl of a large share does not fit in thirty seconds.
2026-10-13 21:26:15 +02:00
nvrl 4b9cbd26bc feat(android): notify when a show gains episodes
NewEpisodesWorker runs crawl_for_news every six hours on a network with
the battery not low, and posts one notification per show, grouped. A
setting under Playback turns it off and cancels the schedule.
2026-10-13 21:26:15 +02:00
nvrl 588a7fcb09 feat(android): crawl for news, and say which titles gained episodes
crawl_for_news crawls the shares due a look that were crawled here
before, and returns per title how many files first appeared during it and
the first of them. A share crawled for the first time is left out, since
every file in it is new.
2026-10-13 21:26:15 +02:00
nvrl e3e855a02d docs: sidecar subtitles are no longer a gap 2026-10-13 21:11:20 +02:00
nvrl 7790ae9cbb 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.
2026-10-13 21:11:20 +02:00
nvrl f0fe90f0fe feat(core,app,cli): pair subtitle files with their video and play them
A crawl now keeps .srt/.ass/.ssa/.vtt files and pairs each with a video by
name: the video's name followed by a dot or nothing, the same inside a
Subs folder, the Subs/<video>/ layout, and failing those the only video in
the folder. The words past the video's name give the language and the
picker's title. They go in a new sidecars table (schema v12), rebuilt by
every crawl.

The desktop opens them while mpv opens the video and adds them with
sub-add through the stream registry once the file has loaded. One in the
preferred language is turned on when subtitles are on and the file has
none of its own playing; the rest wait in the subtitle menu.
2026-10-13 21:11:20 +02:00
nvrl 51454b5a6e refactor(core,player): move the language table into pstr-core
The crawl needs it to read a subtitle file's language from its name
(en, eng, English), and pstr-player re-exports it unchanged.
2026-10-13 21:11:20 +02:00
nvrl c44b04cf4a fix(ios): keep a crawl running when the app leaves the screen
Without a background task iOS suspends the app at once and drops its
connections, so a glance at another app failed a minutes-long crawl.
2026-10-13 20:45:14 +02:00
nvrl 85bc975f5f fix(core,android,app): say why a crawl failed, not only which URL
A transport failure's text is reqwest's "error sending request for url
(…)", and the cause sits further down its source chain. Error::describe
appends the causes and drops the URL, so the reason fits on a share row.
2026-10-13 20:45:14 +02:00
nvrl 4f130d1d6e fix(core): list a folder again when its connection drops mid-crawl
A crawl is hundreds of listings and the first failure ended it. The SDK
replays a timeout or a refused connection, not a request whose connection
dropped, which is what a phone sees after the OS tears down its sockets or
hands over between networks. A listing is read-only, so the crawl retries
it after 1, 3 and 9 seconds.
2026-10-13 20:45:14 +02:00
44 changed files with 2145 additions and 143 deletions

No files matched your search

Generated
+7 -7
View File
@@ -5278,7 +5278,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures", "futures",
@@ -5298,7 +5298,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5332,7 +5332,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5351,7 +5351,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -5371,7 +5371,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -5384,7 +5384,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -5399,7 +5399,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "1.5.0" version = "1.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.5.0" version = "1.6.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
+3 -3
View File
@@ -24,9 +24,9 @@ layer and the player are done and verified against a real share, and the app has
a library wall, a page per title with seasons and episodes, a library wall, a page per title with seasons and episodes,
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
button for openings and endings, next/previous episode with an "up next" button for openings and endings, next/previous episode with an "up next"
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files countdown, subtitle files beside the video, optional metadata, themes and share
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the management. What is left is mostly running the macOS packaging on a real Mac. See
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`. `docs/DEVELOPMENT.md`.
A native Android 12+ client is under `android/`, sharing the Rust catalog and A native Android 12+ client is under `android/`, sharing the Rust catalog and
streaming implementation through UniFFI. It features a modern Material 3 interface, streaming implementation through UniFFI. It features a modern Material 3 interface,
+9
View File
@@ -7,6 +7,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Android TV: listed in its launcher, and installable without a touch
screen. Neither is required, so phones are offered the same build.
-->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application <application
android:name=".ProtonStreamApplication" android:name=".ProtonStreamApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -14,6 +21,7 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
tools:targetApi="33" tools:targetApi="33"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@drawable/tv_banner"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream"> android:theme="@style/Theme.ProtonStream">
@@ -39,6 +47,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<!-- <!--
Proton Drive public links. autoVerify="false", said explicitly: Proton Drive public links. autoVerify="false", said explicitly:
+103 -3
View File
@@ -27,6 +27,7 @@ extern "C" {
#include <string_view> #include <string_view>
#include <thread> #include <thread>
#include <utility> #include <utility>
#include <vector>
namespace { namespace {
@@ -210,6 +211,17 @@ struct PlaybackState {
/// episode one — which is the difference between resuming an episode where /// episode one — which is the difference between resuming an episode where
/// the viewer left it and resuming it where the *previous* one ended. /// the viewer left it and resuming it where the *previous* one ended.
int64_t generation = 0; int64_t generation = 0;
/// mpv has loaded this generation's file and made its own track pick,
/// which is the first moment the pick can be second-guessed.
bool loaded = false;
};
/// 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 { class Player {
@@ -338,7 +350,10 @@ class Player {
state_.generation = generation; state_.generation = generation;
state_.loading = true; state_.loading = true;
error_.clear(); 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 std::string url = "pstr://" + std::to_string(handle);
const char *command[] = {"loadfile", url.c_str(), nullptr}; const char *command[] = {"loadfile", url.c_str(), nullptr};
if (mpv_command(mpv_, command) < 0) { if (mpv_command(mpv_, command) < 0) {
@@ -372,6 +387,27 @@ class Player {
mpv_command(mpv_, command); 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 { PlaybackState state() const {
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
return state_; return state_;
@@ -395,6 +431,8 @@ class Player {
char *lang_raw = nullptr; char *lang_raw = nullptr;
char *title_raw = nullptr; char *title_raw = nullptr;
int selected = 0; int selected = 0;
int is_default = 0;
int forced = 0;
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id); mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw); mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
MpvString type(type_raw); MpvString type(type_raw);
@@ -403,10 +441,14 @@ class Player {
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw); mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
MpvString title(title_raw); MpvString title(title_raw);
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected); mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
mpv_get_property(mpv_, (base + "default").c_str(), MPV_FORMAT_FLAG, &is_default);
mpv_get_property(mpv_, (base + "forced").c_str(), MPV_FORMAT_FLAG, &forced);
if (i) json += ','; if (i) json += ',';
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) + json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" + "\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}"; escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") +
",\"default\":" + (is_default ? "true" : "false") +
",\"forced\":" + (forced ? "true" : "false") + "}";
} }
return json + ']'; return json + ']';
} }
@@ -434,6 +476,35 @@ class Player {
} }
private: 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 { struct MpvFree {
void operator()(char *value) const noexcept { if (value) mpv_free(value); } void operator()(char *value) const noexcept { if (value) mpv_free(value); }
}; };
@@ -534,6 +605,17 @@ class Player {
if (event->event_id == MPV_EVENT_FILE_LOADED) { if (event->event_id == MPV_EVENT_FILE_LOADED) {
const double start = pending_start_.exchange(0.0); const double start = pending_start_.exchange(0.0);
if (start > 0.0) set_double("time-pos", start); 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;
state_.loaded = true;
ready.swap(pending_subtitles_);
}
}
for (const auto &subtitle : ready) attach_subtitle(subtitle);
} }
if (event->event_id == MPV_EVENT_END_FILE) { if (event->event_id == MPV_EVENT_END_FILE) {
const auto *end = static_cast<mpv_event_end_file *>(event->data); const auto *end = static_cast<mpv_event_end_file *>(event->data);
@@ -757,6 +839,12 @@ class Player {
bool surface_changed_ = false; bool surface_changed_ = false;
std::atomic<bool> frame_ready_{false}; std::atomic<bool> frame_ready_{false};
std::atomic<double> pending_start_{0.0}; 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); } Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
@@ -850,6 +938,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
: JNI_FALSE); : 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 { 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); }); c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
} }
@@ -881,8 +980,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
state.video_width, state.video_height, state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason), static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent, state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0, state.cached_until}; state.seeking ? 1.0 : 0.0, state.cached_until,
constexpr jsize kFields = 14; state.loaded ? 1.0 : 0.0};
constexpr jsize kFields = 15;
jdoubleArray result = env->NewDoubleArray(kFields); jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr); if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, kFields, values); env->SetDoubleArrayRegion(result, 0, kFields, values);
@@ -21,6 +21,7 @@ import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import io.narl.protonstream.sync.WatchSyncWorker import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
@@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() {
playbackConnection, playbackConnection,
Context.BIND_AUTO_CREATE, Context.BIND_AUTO_CREATE,
) )
NewEpisodesWorker.schedule(this)
if ( if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
@@ -19,7 +19,10 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan import uniffi.pstr_android.chapterPlan
import uniffi.pstr_android.fullSubtitlesInstead
/** /**
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients * Why the open file ended, mirroring `pstr_player::EndReason` so both clients
@@ -107,6 +110,10 @@ data class MpvTrack(
val language: String, val language: String,
val title: String, val title: String,
val selected: Boolean, val selected: Boolean,
/** The container marks it as the default choice. */
val isDefault: Boolean = false,
/** The container marks it as forced: on-screen text only, by convention. */
val forced: Boolean = false,
) { ) {
val label: String get() = listOf(language, title).filter(String::isNotBlank) val label: String get() = listOf(language, title).filter(String::isNotBlank)
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" } .joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
@@ -128,6 +135,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
@Volatile private var currentMedia: String? = null @Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0 @Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null @Volatile private var plannedFor: Pair<Double, Long>? = null
/** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null var onExplicitStop: (() -> Unit)? = null
@@ -292,6 +301,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong()) 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() { private fun startPolling() {
if (poller?.isActive == true) return if (poller?.isActive == true) return
// Off the main thread deliberately: `nativeState` and `nativeTracks` // Off the main thread deliberately: `nativeState` and `nativeTracks`
@@ -322,6 +355,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
// both of which are the main thread's. // both of which are the main thread's.
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) } withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
refreshChapters(next) refreshChapters(next)
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
subtitlesCheckedFor != currentGeneration
) {
subtitlesCheckedFor = currentGeneration
preferFullSubtitles()
}
} }
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it } withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks() if (tick++ % 4 == 0) refreshTracks()
@@ -359,13 +398,32 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
} }
} }
/**
* Play the dialogue rather than the signs and songs mpv picked by language
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
* have made, not the viewer's.
*/
private fun preferFullSubtitles() {
refreshTracks()
val subtitles = mutableTracks.value.filter { it.type == "sub" }
val id = fullSubtitlesInstead(
subtitles.map {
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
},
) ?: return
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
}
private fun refreshTracks() { private fun refreshTracks() {
runCatching { runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return val encoded = withOpenHandle(::nativeTracks) ?: return
val values = JSONArray(encoded) val values = JSONArray(encoded)
List(values.length()) { index -> List(values.length()) { index ->
values.getJSONObject(index).run { values.getJSONObject(index).run {
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected")) MpvTrack(
getLong("id"), getString("type"), getString("language"), getString("title"),
getBoolean("selected"), optBoolean("default"), optBoolean("forced"),
)
} }
} }
}.onSuccess { mutableTracks.value = it } }.onSuccess { mutableTracks.value = it }
@@ -414,6 +472,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
subtitles: Boolean, subtitles: Boolean,
hardwareDecoding: Boolean, hardwareDecoding: Boolean,
): 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 nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double) private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double) private external fun nativeVolume(handle: Long, volume: Double)
@@ -434,7 +500,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
companion object { companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */ /** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 14 private const val FIELDS = 15
private const val GENERATION = 6 private const val GENERATION = 6
private const val WIDTH = 7 private const val WIDTH = 7
private const val HEIGHT = 8 private const val HEIGHT = 8
@@ -443,6 +509,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private const val CACHE_PERCENT = 11 private const val CACHE_PERCENT = 11
private const val SEEKING = 12 private const val SEEKING = 12
private const val CACHED_UNTIL = 13 private const val CACHED_UNTIL = 13
private const val LOADED = 14
init { init {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
@@ -68,6 +68,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.focusable
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -250,11 +258,19 @@ fun PlayerScreen(
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles, startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
SettingsStore(context).hardwareDecoding, SettingsStore(context).hardwareDecoding,
) )
// After the load, because mpv keeps none of the three across one. // After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume) nativeHost.setVolume(startup.volume)
nativeHost.setMuted(startup.muted) nativeHost.setMuted(startup.muted)
nativeHost.setSpeed(startup.speed) nativeHost.setSpeed(startup.speed)
session.transferToPlayer() 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" } }.onFailure { playbackError = it.message ?: "Playback failed" }
opening = false opening = false
} }
@@ -436,12 +452,46 @@ fun PlayerScreen(
} }
} }
val current by rememberUpdatedState(state) val current by rememberUpdatedState(state)
// Where a remote's keys land while the controls are down. Once they are
// up, the D-pad moves between their buttons as it does anywhere else.
val keys = remember { FocusRequester() }
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
if (minimized) return if (minimized) return
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
// A TV remote, a keyboard: left and right seek as a double tap
// does, the middle button brings the controls up, and play/pause
// and space do what they say.
.focusRequester(keys)
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionLeft, Key.DirectionRight -> {
if (showControls) return@onKeyEvent false
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
host.seek((current.position + seconds).coerceAtLeast(0.0))
nudge = seconds to (nudge?.second ?: 0) + 1
true
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
if (showControls) return@onKeyEvent false
showControls = true
true
}
Key.MediaPlayPause, Key.Spacebar -> {
host.setPaused(!current.paused)
showControls = true
true
}
Key.MediaPlay -> { host.setPaused(false); true }
Key.MediaPause -> { host.setPaused(true); true }
else -> false
}
}
.focusable()
// Double-tap a side to seek ten seconds, as every phone video // Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap // player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both. // waits out the double-tap timeout, which is the price of both.
@@ -56,6 +56,11 @@ class SettingsStore(context: Context) {
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true) get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } } set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
/** Whether a background crawl may say that a show gained episodes. */
var newEpisodeNotifications: Boolean
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
companion object { companion object {
/** The desktop's range, in the steps a phone's storage is bought in. */ /** The desktop's range, in the steps a phone's storage is bought in. */
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16) val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
@@ -78,6 +83,7 @@ class SettingsStore(context: Context) {
private const val KEY_LIBRARY_SORT = "library_sort" private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped" private const val KEY_LIBRARY_GROUPED = "library_grouped"
private const val KEY_LIBRARY_FILTER = "library_filter" private const val KEY_LIBRARY_FILTER = "library_filter"
private const val KEY_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed" private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
} }
} }
@@ -0,0 +1,136 @@
package io.narl.protonstream.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import uniffi.pstr_android.NewEpisodesRecord
import java.util.concurrent.TimeUnit
/**
* Look at the shares while the app is away, and say when a show gained episodes.
*
* The app crawls a share it has not looked at for six hours whenever it opens,
* which finds a new episode only once someone opens it to look. This runs the
* same crawl on WorkManager's schedule instead. It only reports files the
* crawl itself found, so a share the app crawled since says nothing twice.
*/
class NewEpisodesWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
if (news.isNotEmpty()) post(news)
return Result.success()
}
private fun post(news: List<NewEpisodesRecord>) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "A show in your library gained episodes"
},
)
val open = PendingIntent.getActivity(
applicationContext,
0,
Intent(applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val compat = NotificationManagerCompat.from(applicationContext)
// One per show, keyed by it, so a second crawl's news for the same show
// replaces the first rather than stacking under it.
news.forEach { item ->
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle(item.title)
.setContentText(describe(item))
.setContentIntent(open)
.setGroup(GROUP)
.setAutoCancel(true)
.build()
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
}
if (news.size > 1) {
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle("New episodes")
.setContentText(news.joinToString { it.title })
.setContentIntent(open)
.setGroup(GROUP)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
}
}
companion object {
private const val WORK_NAME = "new-episodes"
private const val CHANNEL_ID = "new-episodes"
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
private const val SUMMARY_TAG = "new-episodes-summary"
private const val NOTIFICATION_ID = 7
private const val INTERVAL_HOURS = 6L
/** What one show's notification says: "S01E04 and 2 more". */
fun describe(news: NewEpisodesRecord): String {
val more = news.count.toInt() - 1
val first = news.first
return when {
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
more == 0 -> "$first is new"
more == 1 -> "$first and 1 more are new"
else -> "$first and $more more are new"
}
}
/**
* Keep the periodic look scheduled, or cancel it when the viewer
* turned it off. Called on every launch; keeping an existing schedule
* means a launch does not push the next look back.
*/
fun schedule(context: Context) {
val work = WorkManager.getInstance(context.applicationContext)
if (!SettingsStore(context).newEpisodeNotifications) {
work.cancelUniqueWork(WORK_NAME)
return
}
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build(),
)
.build()
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}
@@ -59,6 +59,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -104,6 +105,7 @@ internal fun SettingsScreen(
val settings = remember { SettingsStore(context) } val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) } var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) } var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) } var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
// Playback preferences are the shared store's, not this app's: they are the // Playback preferences are the shared store's, not this app's: they are the
@@ -207,6 +209,15 @@ internal fun SettingsScreen(
backgroundAudio = it backgroundAudio = it
settings.backgroundAudio = it settings.backgroundAudio = it
} }
SettingSwitch(
"New episode notifications",
"Looks at your shares every few hours and says when a show gains episodes",
newEpisodes,
) {
newEpisodes = it
settings.newEpisodeNotifications = it
NewEpisodesWorker.schedule(context)
}
} }
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) { SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) { SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+280 -8
View File
@@ -13,7 +13,9 @@ use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn}; use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::{Filter, Sort}; use pstr_core::browse::{Filter, Sort};
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; use pstr_core::catalog::{
Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
};
use pstr_core::chapters::{Chapter, ChapterRole}; use pstr_core::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Episode, Library, Title, TitleKind}; use pstr_core::library::{Episode, Library, Title, TitleKind};
@@ -25,6 +27,7 @@ use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential; use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
use pstr_core::shares::AccountFolder; use pstr_core::shares::AccountFolder;
use pstr_core::subtitles::SubtitleTrack;
use pstr_core::sync::WatchSync; use pstr_core::sync::WatchSync;
use pstr_core::{ use pstr_core::{
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary, Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
@@ -102,6 +105,36 @@ pub struct AndroidPaths {
pub cache: String, pub cache: String,
} }
/// A subtitle file beside an episode, opened and published for the native
/// player like the episode itself. The player releases `handle` when mpv
/// closes it; one never handed to mpv goes back through
/// `pstr_android_stream_release`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SubtitleFileRecord {
pub handle: u64,
/// What else the file name says — "forced", "SDH" — or "External".
pub title: String,
pub language: Option<String>,
}
/// An episode's subtitle files, and which to turn on when the episode has
/// none of its own playing.
#[derive(Debug, Clone, Default, uniffi::Record)]
pub struct SubtitleFilesRecord {
pub files: Vec<SubtitleFileRecord>,
pub preferred: Option<u32>,
}
/// Episodes of one title that a background crawl found, for a notification.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NewEpisodesRecord {
pub title_key: String,
pub title: String,
pub count: u32,
/// The first new episode's label — "S01E04" — when there is one.
pub first: Option<String>,
}
/// Android implements this with an AES-GCM key held by Android Keystore. /// Android implements this with an AES-GCM key held by Android Keystore.
#[uniffi::export(callback_interface)] #[uniffi::export(callback_interface)]
pub trait AndroidSecretStore: Send + Sync { pub trait AndroidSecretStore: Send + Sync {
@@ -707,6 +740,38 @@ fn chapter_kind(role: ChapterRole) -> ChapterKind {
} }
} }
/// One subtitle track as the player read it out of mpv's `track-list`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SubtitleTrackRecord {
pub id: i64,
pub language: Option<String>,
pub title: Option<String>,
pub default: bool,
pub forced: bool,
pub selected: bool,
}
/// The dialogue track to play in place of the subtitle track mpv chose, when
/// mpv chose the signs and songs (`pstr_core::subtitles`). Asked once per file,
/// as soon as mpv has loaded it and made its own pick.
#[uniffi::export]
pub fn full_subtitles_instead(tracks: Vec<SubtitleTrackRecord>) -> Option<i64> {
let tracks: Vec<SubtitleTrack> = tracks
.into_iter()
.map(|track| SubtitleTrack {
id: track.id,
language: track
.language
.filter(|language| !language.trim().is_empty()),
title: track.title.filter(|title| !title.trim().is_empty()),
default: track.default,
forced: track.forced,
selected: track.selected,
})
.collect();
pstr_core::subtitles::full_instead(&tracks)
}
/// What the app is holding on disk. Offline episodes are the viewer's, the /// What the app is holding on disk. Offline episodes are the viewer's, the
/// block cache is not: only the latter may be reclaimed without asking. /// block cache is not: only the latter may be reclaimed without asking.
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
@@ -1313,6 +1378,49 @@ impl AndroidEngine {
.collect()) .collect())
} }
/// Crawl the shares due a look and say which titles gained episodes, for
/// a notification from a background job.
///
/// Only shares crawled here before: the first crawl of one finds every
/// file new, and "312 new episodes" is not news. A share that fails is
/// skipped, so one revoked link does not hide another's episodes.
pub async fn crawl_for_news(self: Arc<Self>) -> Result<Vec<NewEpisodesRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let crawls = self
.catalog
.lock()
.crawls()
.map_err(BridgeError::from_display)?;
let due: Vec<String> = self
.uncrawled_shares()?
.into_iter()
.filter(|id| crawls.get(id).is_some_and(|record| record.crawled_at > 0))
.collect();
if due.is_empty() {
return Ok(Vec::new());
}
let since = now();
for id in due {
if let Err(error) = self.crawl_inner(Some(id)).await {
log::debug!("background crawl failed: {error}");
}
}
self.with_library(|cached| {
let mut news = news_since(&cached.library, &cached.first_seen, since);
// By the name the library shows, which may be the provider's.
for item in &mut news {
if let Some(record) = cached.titles.iter().find(|t| t.key == item.title_key) {
item.title.clone_from(&record.display_name);
}
}
news
})
})
.await
.map_err(BridgeError::from_display)?
}
/// Every configured share, with what its last crawl here came to. /// Every configured share, with what its last crawl here came to.
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> { pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
let shares = self.store.list().map_err(BridgeError::from_display)?; let shares = self.store.list().map_err(BridgeError::from_display)?;
@@ -1972,6 +2080,83 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
/// Open the subtitle files paired with an episode, for the native player
/// to add once it has the episode loaded. `language` is the subtitle
/// language in effect for it, which picks [`SubtitleFilesRecord::preferred`].
///
/// Through the session already open: a downloaded episode played without
/// one goes without its subtitles rather than waiting on a handshake. One
/// that fails to open is left out; the episode plays either way.
pub async fn open_subtitles(
self: Arc<Self>,
share_id: String,
link_id: String,
language: Option<String>,
) -> SubtitleFilesRecord {
let runtime = Arc::clone(&self.runtime);
let opened = spawned(&runtime, async move {
let sidecars = self
.catalog
.lock()
.sidecars(&share_id, &link_id)
.unwrap_or_default();
if sidecars.is_empty() {
return SubtitleFilesRecord::default();
}
let Some(connection) = self.cached_connection() else {
return SubtitleFilesRecord::default();
};
let (engine, connection) = (&self, &connection);
let opening = sidecars.into_iter().map(|sidecar| async move {
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
match engine
.open_from_source(connection, &sidecar.share_id, &uid)
.await
{
Ok(stream) => {
stream.warm_edges();
Some((sidecar, stream))
}
Err(error) => {
log::debug!("opening subtitle {} failed: {error}", sidecar.name);
None
}
}
});
let opened: Vec<_> = futures::future::join_all(opening)
.await
.into_iter()
.flatten()
.collect();
let sidecars: Vec<_> = opened.iter().map(|(sidecar, _)| sidecar.clone()).collect();
let preferred = pstr_core::catalog::preferred_sidecar(&sidecars, language.as_deref())
.and_then(|index| u32::try_from(index).ok());
let files = opened
.into_iter()
.map(|(sidecar, stream)| {
let handle = Arc::new(AndroidStream {
runtime: Arc::clone(&self.runtime),
stream,
cancel: tokio::sync::Notify::new(),
native_id: Mutex::new(None),
})
.native_handle();
SubtitleFileRecord {
handle,
title: sidecar.title.unwrap_or_else(|| "External".to_owned()),
language: sidecar.language,
}
})
.collect();
SubtitleFilesRecord { files, preferred }
})
.await;
opened.unwrap_or_else(|error| {
log::debug!("opening subtitles stopped: {error}");
SubtitleFilesRecord::default()
})
}
/// Fetch the blocks an episode opens with into the cache, without playing /// Fetch the blocks an episode opens with into the cache, without playing
/// it: the next episode, near the end of this one, so that autoplay or /// it: the next episode, near the end of this one, so that autoplay or
/// "Play now" starts from the cache rather than from two round trips. /// "Play now" starts from the cache rather than from two round trips.
@@ -2321,6 +2506,7 @@ impl AndroidEngine {
added, added,
titles, titles,
offline: offline.into_keys().collect(), offline: offline.into_keys().collect(),
first_seen,
}; };
let answer = read(&cached); let answer = read(&cached);
*self.library_cache.lock() = Some(cached); *self.library_cache.lock() = Some(cached);
@@ -2637,18 +2823,22 @@ impl AndroidEngine {
if let Err(error) = self if let Err(error) = self
.catalog .catalog
.lock() .lock()
.record_crawl_failure(&id, &error.to_string()) .record_crawl_failure(&id, &error.describe())
{ {
log::warn!("record a failed crawl: {error}"); log::warn!("record a failed crawl: {error}");
} }
return Err(BridgeError::from_display(error)); return Err(BridgeError::from_display(error.describe()));
} }
}; };
let rows = build_rows(&id, &nodes); let rows = build_rows(&id, &nodes);
self.catalog let sidecars = build_sidecars(&id, &nodes);
.lock() {
.replace_share(&id, &rows) let mut catalog = self.catalog.lock();
.map_err(BridgeError::from_display)?; catalog
.replace_share(&id, &rows)
.and_then(|()| catalog.replace_sidecars(&id, &sidecars))
.map_err(BridgeError::from_display)?;
}
let after = self let after = self
.catalog .catalog
.lock() .lock()
@@ -3196,6 +3386,40 @@ struct CachedLibrary {
titles: Vec<TitleRecord>, titles: Vec<TitleRecord>,
/// The files kept on this device, by share and link id. /// The files kept on this device, by share and link id.
offline: HashSet<(String, String)>, offline: HashSet<(String, String)>,
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
first_seen: HashMap<(String, String), i64>,
}
/// The titles whose files first appeared at or after `since`, with how many
/// and the first of them, most new episodes first.
fn news_since(
library: &Library,
first_seen: &HashMap<(String, String), i64>,
since: i64,
) -> Vec<NewEpisodesRecord> {
let mut news: Vec<NewEpisodesRecord> = library
.titles
.iter()
.filter_map(|title| {
let fresh: Vec<&pstr_core::library::Episode> = title
.episodes()
.filter(|episode| {
first_seen
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
.is_some_and(|seen| *seen >= since)
})
.collect();
let first = fresh.first()?;
Some(NewEpisodesRecord {
title_key: title.key.clone(),
title: title.name.clone(),
count: u32::try_from(fresh.len()).unwrap_or(u32::MAX),
first: Some(first.label()),
})
})
.collect();
news.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.title.cmp(&b.title)));
news
} }
/// How many provider lookups may be in flight at once. /// How many provider lookups may be in flight at once.
@@ -3561,7 +3785,7 @@ mod tests {
use super::{ use super::{
AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream,
AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker, AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker,
chapter_plan, match_record, node_uid, normalized_language, palette_record, chapter_plan, match_record, news_since, node_uid, normalized_language, palette_record,
prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release, prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release,
pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata, pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata,
write_partial_marker, write_partial_marker,
@@ -4116,6 +4340,54 @@ mod tests {
drop(engine); drop(engine);
std::fs::remove_dir_all(root).expect("remove test directory"); std::fs::remove_dir_all(root).expect("remove test directory");
} }
#[test]
fn news_is_the_titles_that_gained_files_since_the_crawl_began_most_first() {
let file = |link: &str, name: &str| pstr_core::catalog::CatalogNode {
share_id: "s".into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.into(),
is_folder: false,
media_type: None,
size: None,
active_revision_id: None,
parsed: pstr_core::naming::parse(name),
};
let library = pstr_core::library::Library::build(
vec![
file("f1", "Frieren S01E01.mkv"),
file("f2", "Frieren S01E02.mkv"),
file("f3", "Frieren S01E03.mkv"),
file("m1", "Mushishi S01E01.mkv"),
file("m2", "Mushishi S01E02.mkv"),
file("o1", "Monster S01E01.mkv"),
],
&HashMap::new(),
);
let seen = |link: &str, at: i64| (("s".to_string(), link.to_string()), at);
let first_seen: HashMap<_, _> = [
seen("f1", 10),
seen("f2", 100),
seen("f3", 100),
seen("m1", 10),
seen("m2", 100),
seen("o1", 10),
]
.into();
let news: Vec<(String, u32, Option<String>)> = news_since(&library, &first_seen, 100)
.into_iter()
.map(|news| (news.title, news.count, news.first))
.collect();
assert_eq!(
news,
vec![
("Frieren".to_string(), 2, Some("S01E02".to_string())),
("Mushishi".to_string(), 1, Some("S01E02".to_string())),
]
);
}
} }
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
+2 -2
View File
@@ -25,6 +25,8 @@ tracing-subscriber.workspace = true
eframe.workspace = true eframe.workspace = true
egui.workspace = true egui.workspace = true
egui-phosphor.workspace = true egui-phosphor.workspace = true
# The parser egui reads fonts with, to check a system font before handing it over.
skrifa.workspace = true
image.workspace = true image.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
@@ -39,8 +41,6 @@ tao.workspace = true
[build-dependencies] [build-dependencies]
embed-resource.workspace = true embed-resource.workspace = true
[dev-dependencies]
skrifa.workspace = true
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
dbus.workspace = true dbus.workspace = true
+53 -3
View File
@@ -28,7 +28,7 @@ use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn}; use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
use pstr_core::appearance::Appearance; use pstr_core::appearance::Appearance;
use pstr_core::catalog::{ use pstr_core::catalog::{
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
}; };
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
@@ -1494,7 +1494,7 @@ impl Engine {
if let Err(error) = self if let Err(error) = self
.catalog .catalog
.lock() .lock()
.record_crawl_failure(&share_id, &error.to_string()) .record_crawl_failure(&share_id, &error.describe())
{ {
tracing::warn!(%error, "record a failed crawl"); tracing::warn!(%error, "record a failed crawl");
} }
@@ -1513,6 +1513,7 @@ impl Engine {
} }
}; };
let rows = build_rows(&share_id, &nodes); let rows = build_rows(&share_id, &nodes);
let sidecars = build_sidecars(&share_id, &nodes);
let files = rows.len(); let files = rows.len();
// A writer opened before the crawl may be producing the revision // A writer opened before the crawl may be producing the revision
@@ -1574,7 +1575,7 @@ impl Engine {
for (link_id, _) in &stale { for (link_id, _) in &stale {
catalog.remove_offline_file(&share_id, link_id)?; catalog.remove_offline_file(&share_id, link_id)?;
} }
Ok(()) catalog.replace_sidecars(&share_id, &sidecars)
}) })
}; };
if let Err(error) = stored { if let Err(error) = stored {
@@ -1718,6 +1719,55 @@ impl Engine {
}); });
} }
/// Open the subtitle files paired with `target`, for the player to add
/// once mpv has the video.
///
/// Through the session already open: a local copy played without one goes
/// without its subtitles rather than waiting on a handshake. Each is
/// warmed — most are one block — so mpv reads it from the cache. One that
/// fails is left out quietly; the video plays either way.
pub async fn open_sidecars(
&self,
target: &PlaybackTarget,
) -> Vec<(pstr_core::catalog::Sidecar, VideoStream)> {
let sidecars = self
.catalog
.lock()
.sidecars(&target.share_id, &target.link_id)
.unwrap_or_default();
if sidecars.is_empty() {
return Vec::new();
}
let Some((library, source)) = self.opened() else {
return Vec::new();
};
let opening = sidecars.into_iter().map(|sidecar| {
let library = Arc::clone(&library);
let source = source.clone();
async move {
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
match library
.reauthenticating(&sidecar.share_id, || source.open(&sidecar.share_id, &uid))
.await
{
Ok(stream) => {
stream.warm_edges();
Some((sidecar, stream))
}
Err(error) => {
tracing::warn!(%error, "open subtitle {}", sidecar.name);
None
}
}
}
});
futures::future::join_all(opening)
.await
.into_iter()
.flatten()
.collect()
}
/// How far into the file `target` resumes, as a fraction of its running /// How far into the file `target` resumes, as a fraction of its running
/// time — known from the last time it played. /// time — known from the last time it played.
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> { fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
+76 -2
View File
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
use pstr_core::library::{Episode, Title}; use pstr_core::library::{Episode, Title};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_player::{ use pstr_player::{
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind, Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
VideoOutput, TrackKind, VideoOutput,
}; };
use pstr_stream::VideoStream; use pstr_stream::VideoStream;
@@ -530,6 +530,15 @@ fn run(
} }
}; };
// Opened while mpv opens the video, and added once it has: an external
// track belongs to the file loaded at the time.
let mut sidecars = Some(engine.runtime().spawn({
let engine = engine.clone();
let target = target.clone();
async move { engine.open_sidecars(&target).await }
}));
let mut subtitles: Vec<StreamHandle> = Vec::new();
let mut position = target.resume_at.unwrap_or(0.0); let mut position = target.resume_at.unwrap_or(0.0);
let mut duration: Option<f64> = None; let mut duration: Option<f64> = None;
let mut watched = false; let mut watched = false;
@@ -558,6 +567,10 @@ fn run(
match &event { match &event {
PlayerEvent::FileLoaded => { PlayerEvent::FileLoaded => {
duration = player.duration(); duration = player.duration();
if let Some(opening) = sidecars.take() {
let opened = engine.runtime().block_on(opening).unwrap_or_default();
subtitles = add_sidecars(&engine, &target, &player, opened);
}
// The first moment there is a track list to read: mpv has // The first moment there is a track list to read: mpv has
// demuxed the file and made its own default selection. Put the // demuxed the file and made its own default selection. Put the
// title's own choice over it before the menus are told, so they // title's own choice over it before the menus are told, so they
@@ -565,6 +578,15 @@ fn run(
if let Some(show) = target.track_prefs.as_deref() { if let Some(show) = target.track_prefs.as_deref() {
restore_tracks(&player, show); restore_tracks(&player, show);
} }
// A subtitle track picked by name stays; one mpv picked by
// language may be the signs and songs, not the dialogue.
let picked = target
.track_prefs
.as_deref()
.is_some_and(|show| show.subtitle_title.is_some());
if !picked {
prefer_full_subtitles(&player);
}
emit_tracks(&engine, id, &player); emit_tracks(&engine, id, &player);
} }
PlayerEvent::Duration(value) => duration = Some(*value), PlayerEvent::Duration(value) => duration = Some(*value),
@@ -607,6 +629,7 @@ fn run(
// the other `Arc`, and mpv must be destroyed there, on the thread whose // the other `Arc`, and mpv must be destroyed there, on the thread whose
// OpenGL context its render context was built against. // OpenGL context its render context was built against.
drop(handle); drop(handle);
drop(subtitles);
drop(player); drop(player);
engine.release( engine.release(
@@ -721,6 +744,57 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
} }
} }
/// Play the dialogue rather than the signs and songs mpv picked by language.
/// Not remembered: it is the choice mpv should have made, not the viewer's.
fn prefer_full_subtitles(player: &Player) {
if let Some(track) = pstr_player::full_subtitles_instead(&player.tracks())
&& let Err(error) = player.select_track(TrackKind::Subtitle, Some(track))
{
tracing::warn!("select dialogue subtitle {track}: {error}");
}
}
/// Hand mpv the subtitle files paired with the video it has just loaded.
///
/// One is turned on only when subtitles are on and the video has none of its
/// own playing — a file's own track was chosen by name and language already,
/// and an external one is the fallback, not an override.
fn add_sidecars(
engine: &Engine,
target: &PlaybackTarget,
player: &Player,
opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>,
) -> Vec<StreamHandle> {
let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref());
let playing = player
.tracks()
.iter()
.any(|track| track.kind == TrackKind::Subtitle && track.selected);
let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
let select = (config.subtitles && !playing)
.then(|| {
pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref())
})
.flatten();
sidecars
.iter()
.zip(streams)
.enumerate()
.filter_map(|(index, (sidecar, stream))| {
let title = sidecar.title.as_deref().unwrap_or("External");
player
.add_subtitle(
stream,
title,
sidecar.language.as_deref(),
select == Some(index),
)
.inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name))
.ok()
})
.collect()
}
/// Tell the UI what the file contains and what of it is playing. /// Tell the UI what the file contains and what of it is playing.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) { fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player { engine.emit(Event::Player {
+43 -3
View File
@@ -248,15 +248,40 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Some((path, index)) = system_cjk_font() else { let Some((path, index)) = system_cjk_font() else {
return; return;
}; };
let Ok(bytes) = std::fs::read(path) else { let Ok(bytes) = std::fs::read(&path) else {
return;
};
let Some(data) = cjk_font_data(bytes, index) else {
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
return; return;
}; };
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
ctx.set_fonts(font_definitions(Some(data))); ctx.set_fonts(font_definitions(Some(data)));
ctx.request_repaint(); ctx.request_repaint();
} }
/// The fallback font, if egui can read it.
///
/// egui panics on a font it cannot parse, and this one comes from whatever the
/// system has installed. So it is parsed here first, the same way egui does,
/// and a font that fails is left out rather than taking the app down.
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
skrifa::FontRef::from_index(&bytes, index).ok()?;
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
Some(data)
}
/// The face within a font collection that fontconfig's `%{index}` names.
///
/// Fontconfig packs a variable font's named instance into the high 16 bits —
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
/// instance is not a face, and passing it to egui as one stopped the app at
/// start.
#[cfg(any(target_os = "linux", test))]
fn fontconfig_face(index: u32) -> u32 {
index & 0xFFFF
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let output = std::process::Command::new("fc-match") let output = std::process::Command::new("fc-match")
@@ -269,6 +294,7 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let index = lines let index = lines
.next() .next()
.and_then(|value| value.parse().ok()) .and_then(|value| value.parse().ok())
.map(fontconfig_face)
.unwrap_or(0); .unwrap_or(0);
path.is_file().then_some((path, index)) path.is_file().then_some((path, index))
} }
@@ -855,6 +881,20 @@ const RAMP_STOPS: usize = 64;
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
assert_eq!(fontconfig_face(262_144), 0);
assert_eq!(fontconfig_face(0x0002_0001), 1);
assert_eq!(fontconfig_face(2), 2);
}
#[test]
fn a_fallback_font_egui_cannot_read_is_left_out() {
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
}
#[test] #[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() { fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _; use skrifa::MetadataProvider as _;
+4 -1
View File
@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::account::{PlaceKind, SignIn}; use pstr_core::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows}; use pstr_core::catalog::{Catalog, build_rows, build_sidecars};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::shares::AccountFolder; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync; use pstr_core::sync::WatchSync;
@@ -425,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
catalog catalog
.replace_share(&share_id, &rows) .replace_share(&share_id, &rows)
.and_then(|()| {
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
})
.with_context(|| format!("store {share_id}"))?; .with_context(|| format!("store {share_id}"))?;
println!( println!(
+420 -1
View File
@@ -24,7 +24,7 @@ use crate::metadata::{
use crate::naming::{self, ParsedName}; use crate::naming::{self, ParsedName};
/// Bump when adding a migration. /// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 11; pub const SCHEMA_VERSION: i64 = 12;
const MIGRATION_V1: &str = r#" const MIGRATION_V1: &str = r#"
CREATE TABLE nodes ( CREATE TABLE nodes (
@@ -225,6 +225,23 @@ CREATE TABLE share_crawls (
); );
"#; "#;
/// Subtitle files beside a video, which the crawl used to drop with the
/// `.nfo` files. Rebuilt by every crawl, like `nodes`; a subtitle belongs to
/// one video, the one it was paired with by name.
const MIGRATION_V12: &str = r#"
CREATE TABLE sidecars (
share_id TEXT NOT NULL,
link_id TEXT NOT NULL,
volume_id TEXT NOT NULL,
video_link_id TEXT NOT NULL,
name TEXT NOT NULL,
language TEXT,
title TEXT,
PRIMARY KEY (share_id, link_id)
);
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
"#;
/// How old a share's last crawl may be before an app looks again on its own. /// How old a share's last crawl may be before an app looks again on its own.
/// Long enough that a launch rarely costs a crawl; short enough that a new /// Long enough that a launch rarely costs a crawl; short enough that a new
/// episode turns up the same day. /// episode turns up the same day.
@@ -329,6 +346,21 @@ pub struct CatalogNode {
pub parsed: ParsedName, pub parsed: ParsedName,
} }
/// A subtitle file paired with the video beside it. See [`build_sidecars`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sidecar {
pub share_id: String,
pub link_id: String,
pub volume_id: String,
/// The video this subtitle is for.
pub video_link_id: String,
pub name: String,
/// The [`crate::language::LANGUAGES`] tag its name states, if any.
pub language: Option<String>,
/// What else its name says about it — "forced", "SDH" — for the picker.
pub title: Option<String>,
}
/// Where playback left off. /// Where playback left off.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct WatchState { pub struct WatchState {
@@ -459,6 +491,12 @@ impl Catalog {
tx.pragma_update(None, "user_version", 11)?; tx.pragma_update(None, "user_version", 11)?;
tx.commit()?; tx.commit()?;
} }
if version < 12 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V12)?;
tx.pragma_update(None, "user_version", 12)?;
tx.commit()?;
}
Ok(()) Ok(())
} }
@@ -547,6 +585,59 @@ impl Catalog {
Ok(()) Ok(())
} }
/// Replace one share's subtitle files with `sidecars`.
///
/// Apart from [`Self::replace_share`] rather than inside it: a subtitle
/// left over from the crawl before is one that fails to open, quietly,
/// which is no reason to make every caller of that pass these along.
pub fn replace_sidecars(&mut self, share_id: &str, sidecars: &[Sidecar]) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute(
"DELETE FROM sidecars WHERE share_id = ?1",
params![share_id],
)?;
{
let mut insert = tx.prepare(
"INSERT OR REPLACE INTO sidecars (
share_id, link_id, volume_id, video_link_id, name, language, title
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for sidecar in sidecars {
insert.execute(params![
share_id,
sidecar.link_id,
sidecar.volume_id,
sidecar.video_link_id,
sidecar.name,
sidecar.language,
sidecar.title,
])?;
}
}
tx.commit()?;
Ok(())
}
/// The subtitle files paired with one video, in name order.
pub fn sidecars(&self, share_id: &str, video_link_id: &str) -> Result<Vec<Sidecar>> {
let mut select = self.conn.prepare_cached(
"SELECT share_id, link_id, volume_id, video_link_id, name, language, title
FROM sidecars WHERE share_id = ?1 AND video_link_id = ?2 ORDER BY name",
)?;
let rows = select.query_map(params![share_id, video_link_id], |row| {
Ok(Sidecar {
share_id: row.get(0)?,
link_id: row.get(1)?,
volume_id: row.get(2)?,
video_link_id: row.get(3)?,
name: row.get(4)?,
language: row.get(5)?,
title: row.get(6)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
/// Record that a crawl of `share_id` failed, keeping what the last /// Record that a crawl of `share_id` failed, keeping what the last
/// finished one stored. /// finished one stored.
pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> { pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> {
@@ -670,6 +761,10 @@ impl Catalog {
params![share_id], params![share_id],
)?; )?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM sidecars WHERE share_id = ?1",
params![share_id],
)?;
tx.execute( tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1", "DELETE FROM offline_files WHERE share_id = ?1",
params![share_id], params![share_id],
@@ -1509,6 +1604,182 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
.collect() .collect()
} }
/// Pair each subtitle file in a crawl with the video it is for.
///
/// By name, the way every player looks for one, in the three layouts shares
/// actually have:
///
/// ```text
/// Show/Season 01/S01E01.mkv + S01E01.srt, S01E01.en.srt, S01E01.en.forced.ass
/// Show/Season 01/Subs/S01E01.en.srt a subtitles folder beside the videos
/// Film (2020)/Subs/Film (2020)/2_English.srt a folder per video inside that
/// ```
///
/// A subtitle whose name starts with no video's is still paired when its
/// folder holds one video only — `Film/film.mkv` beside `English.srt`. One
/// that matches nothing is left out: offering it under the wrong episode is
/// worse than not offering it.
pub fn build_sidecars(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Sidecar> {
use std::collections::HashMap;
let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes
.iter()
.map(|node| (node.uid.link_id.as_str(), node))
.collect();
let parent_of = |node: &proton_drive_rs::Node| {
node.parent_uid
.as_ref()
.and_then(|uid| by_link.get(uid.link_id.as_str()).copied())
};
let mut videos: HashMap<&str, Vec<(&proton_drive_rs::Node, &str)>> = HashMap::new();
for node in nodes {
if !node.is_folder()
&& naming::is_video_file(&node.name)
&& let Some(parent) = &node.parent_uid
{
videos
.entry(parent.link_id.as_str())
.or_default()
.push((node, stem(&node.name)));
}
}
let mut sidecars: Vec<Sidecar> = nodes
.iter()
.filter(|node| !node.is_folder() && naming::is_subtitle_file(&node.name))
.filter_map(|node| {
let own = stem(&node.name);
let parent = parent_of(node)?;
let (video, rest) = if is_subtitles_folder(&parent.name) {
// `Subs/S01E01.en.srt`, for the videos beside `Subs`.
beside(
videos.get(parent.parent_uid.as_ref()?.link_id.as_str())?,
own,
)?
} else if let Some(holder) = parent_of(parent)
&& is_subtitles_folder(&holder.name)
{
// `Subs/<video>/2_English.srt`: the folder is named for the video.
let candidates = videos.get(holder.parent_uid.as_ref()?.link_id.as_str())?;
let (video, _) = candidates
.iter()
.find(|(_, video)| video.eq_ignore_ascii_case(&parent.name))?;
(*video, own)
} else {
beside(videos.get(node.parent_uid.as_ref()?.link_id.as_str())?, own)?
};
let (language, title) = describe_subtitle(rest);
Some(Sidecar {
share_id: share_id.to_owned(),
link_id: node.uid.link_id.to_string(),
volume_id: node.uid.volume_id.to_string(),
video_link_id: video.uid.link_id.to_string(),
name: node.name.clone(),
language,
title,
})
})
.collect();
sidecars.sort_by(|a, b| (&a.video_link_id, &a.name).cmp(&(&b.video_link_id, &b.name)));
sidecars
}
/// Which of a video's subtitle files to turn on when the video itself has
/// no subtitle playing: the first in the preferred language, or with no
/// preference the first of all. One in no stated language is taken over
/// nothing, since it is most often the only one there is.
///
/// Never one in another language: a viewer who asked for English subtitles
/// and gets German ones has not been helped.
pub fn preferred_sidecar(sidecars: &[Sidecar], language: Option<&str>) -> Option<usize> {
let Some(wanted) = language else {
return (!sidecars.is_empty()).then_some(0);
};
sidecars
.iter()
.position(|sidecar| {
sidecar
.language
.as_deref()
.is_some_and(|have| crate::language::same_language(wanted, have))
})
.or_else(|| {
sidecars
.iter()
.position(|sidecar| sidecar.language.is_none())
})
}
/// A file name without its extension.
fn stem(name: &str) -> &str {
name.rsplit_once('.').map_or(name, |(stem, _)| stem)
}
fn is_subtitles_folder(name: &str) -> bool {
["subs", "sub", "subtitles", "subtitle"]
.iter()
.any(|known| known.eq_ignore_ascii_case(name.trim()))
}
/// Which of the videos in one folder a subtitle named `own` is for, and what
/// its name says past the video's.
///
/// The longest video name the subtitle's starts with, followed by a dot or
/// nothing: `S01E1.en` is not `S01E10`'s. Failing that, the only video there.
fn beside<'a, 'b>(
videos: &[(&'a proton_drive_rs::Node, &str)],
own: &'b str,
) -> Option<(&'a proton_drive_rs::Node, &'b str)> {
let lower = own.to_lowercase();
let named = videos
.iter()
.filter_map(|(video, name)| {
let name = name.to_lowercase();
if lower == name {
Some((*video, name.len(), ""))
} else if lower.starts_with(&format!("{name}.")) {
// Lowercasing keeps the length of what these names hold;
// `get` covers the names where it does not.
Some((*video, name.len(), own.get(name.len() + 1..)?))
} else {
None
}
})
.max_by_key(|(_, length, _)| *length)
.map(|(video, _, rest)| (video, rest));
named.or(match videos {
[(video, _)] => Some((*video, own)),
_ => None,
})
}
/// The language and the rest of what a subtitle's name says past its video's:
/// `en.forced` is English, "forced"; `2_English_SDH` is English, "SDH".
///
/// The last word naming a language is the language — in `Film.2020.English`
/// the words before it are the film's. Numbers are an index, not a title.
fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
let words: Vec<&str> = rest
.split(['.', '_', ' ', '-', '(', ')', '[', ']'])
.filter(|word| !word.is_empty())
.collect();
let found = words
.iter()
.enumerate()
.rev()
.find_map(|(index, word)| crate::language::language_tag(word).map(|tag| (index, tag)));
let title_words: Vec<&str> = words
.iter()
.enumerate()
.filter(|(index, word)| {
Some(*index) != found.map(|(at, _)| at) && !word.chars().all(|c| c.is_ascii_digit())
})
.map(|(_, word)| *word)
.collect();
let title = (!title_words.is_empty()).then(|| title_words.join(" "));
(found.map(|(_, tag)| tag.to_owned()), title)
}
/// What a file's ancestors say about it. /// What a file's ancestors say about it.
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Ancestry { struct Ancestry {
@@ -2321,6 +2592,30 @@ mod tests {
assert!(catalog.crawls().expect("read").is_empty()); assert!(catalog.crawls().expect("read").is_empty());
} }
#[test]
fn migration_v12_opens_a_v11_catalog() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
MIGRATION_V9,
MIGRATION_V10,
MIGRATION_V11,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 11)
.expect("set version");
let catalog = Catalog::from_connection(conn).expect("upgrade");
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
}
/// A file keeps the moment it first appeared across recrawls, and one /// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0. /// that was there before this was recorded reads as 0.
#[test] #[test]
@@ -3082,4 +3377,128 @@ mod tests {
None None
); );
} }
// ---- build_sidecars: subtitles paired by name --------------------------
fn paired(sidecars: &[Sidecar]) -> Vec<(&str, &str, Option<&str>, Option<&str>)> {
sidecars
.iter()
.map(|sidecar| {
(
sidecar.name.as_str(),
sidecar.video_link_id.as_str(),
sidecar.language.as_deref(),
sidecar.title.as_deref(),
)
})
.collect()
}
#[test]
fn a_subtitle_named_after_a_video_is_paired_with_it_and_says_its_language() {
let nodes = vec![
root(),
folder("s1", Some("root"), "Season 01"),
video("ep1", "s1", "S01E1.mkv"),
video("ep10", "s1", "S01E10.mkv"),
video("plain", "s1", "S01E1.srt"),
video("en", "s1", "S01E10.en.srt"),
video("forced", "s1", "S01E10.eng.forced.ass"),
video("nfo", "s1", "S01E10.nfo"),
];
assert_eq!(
paired(&build_sidecars("share", &nodes)),
vec![
("S01E1.srt", "ep1", None, None),
("S01E10.en.srt", "ep10", Some("eng"), None),
("S01E10.eng.forced.ass", "ep10", Some("eng"), Some("forced")),
]
);
}
#[test]
fn a_subtitles_folder_beside_the_videos_holds_theirs() {
let nodes = vec![
root(),
folder("film", Some("root"), "Film (2020)"),
video("video", "film", "Film (2020).mkv"),
folder("subs", Some("film"), "Subs"),
video("de", "subs", "Film (2020).German.srt"),
folder("per-video", Some("subs"), "Film (2020)"),
video("sdh", "per-video", "3_English_SDH.srt"),
];
assert_eq!(
paired(&build_sidecars("share", &nodes)),
vec![
("3_English_SDH.srt", "video", Some("eng"), Some("SDH")),
("Film (2020).German.srt", "video", Some("ger"), None),
]
);
}
/// One video in the folder can only be the one; two leave a subtitle
/// named after neither unpaired rather than guessed.
#[test]
fn a_subtitle_named_after_no_video_is_paired_only_when_its_folder_has_one() {
let nodes = vec![
root(),
folder("film", Some("root"), "Film"),
video("video", "film", "film.1080p.mkv"),
video("english", "film", "English.srt"),
folder("s1", Some("root"), "Season 01"),
video("ep1", "s1", "S01E01.mkv"),
video("ep2", "s1", "S01E02.mkv"),
video("stray", "s1", "English.srt"),
];
assert_eq!(
paired(&build_sidecars("share", &nodes)),
vec![("English.srt", "video", Some("eng"), None)]
);
}
#[test]
fn a_recrawl_replaces_a_shares_subtitles_and_removing_the_share_drops_them() {
let mut catalog = Catalog::in_memory().expect("open");
let sidecar = |link: &str| Sidecar {
share_id: "s".into(),
link_id: link.into(),
volume_id: "vol".into(),
video_link_id: "a".into(),
name: format!("{link}.srt"),
language: Some("eng".into()),
title: None,
};
catalog
.replace_sidecars("s", &[sidecar("old")])
.expect("crawl");
catalog
.replace_sidecars("s", &[sidecar("new")])
.expect("recrawl");
assert_eq!(
catalog.sidecars("s", "a").expect("read"),
vec![sidecar("new")]
);
catalog.remove_share("s").expect("remove");
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
}
#[test]
fn the_subtitle_turned_on_is_one_in_the_preferred_language_and_never_another() {
let sidecar = |language: Option<&str>| Sidecar {
share_id: "s".into(),
link_id: "l".into(),
volume_id: "v".into(),
video_link_id: "a".into(),
name: "a.srt".into(),
language: language.map(str::to_owned),
title: None,
};
let ger_eng = [sidecar(Some("ger")), sidecar(Some("eng"))];
assert_eq!(preferred_sidecar(&ger_eng, Some("en")), Some(1));
assert_eq!(preferred_sidecar(&ger_eng, None), Some(0));
assert_eq!(preferred_sidecar(&ger_eng, Some("jpn")), None);
let ger_unknown = [sidecar(Some("ger")), sidecar(None)];
assert_eq!(preferred_sidecar(&ger_unknown, Some("eng")), Some(1));
assert_eq!(preferred_sidecar(&[], None), None);
}
} }
+52
View File
@@ -54,6 +54,36 @@ pub enum Error {
} }
impl Error { impl Error {
/// The message, followed by every cause its own text leaves out.
///
/// A dropped connection reads as reqwest's "error sending request for url
/// (…)" and nothing else: why — a reset, a DNS failure, a certificate — is
/// further down the source chain. That is the part a viewer reporting a
/// failed crawl needs, so the URL, which only says which folder, is left
/// out to keep it on screen.
pub fn describe(&self) -> String {
let mut text = match self {
Self::Drive(proton_sdk::error::ProtonError::Transport(error)) => {
let url = error.url().map(|url| format!(" for url ({url})"));
let own = self.to_string();
url.map_or_else(|| own.clone(), |url| own.replace(&url, ""))
}
_ => self.to_string(),
};
let mut seen = self.to_string();
let mut source = std::error::Error::source(self);
while let Some(cause) = source {
let line = cause.to_string();
if !seen.contains(&line) {
text.push_str(": ");
text.push_str(&line);
seen.push_str(&line);
}
source = cause.source();
}
text
}
/// Whether this is the account's session having ended: the refresh token /// Whether this is the account's session having ended: the refresh token
/// was revoked or expired, or the token lacks a scope only a password /// was revoked or expired, or the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it. /// sign-in grants. Nothing but signing in again fixes it.
@@ -76,3 +106,25 @@ impl From<keyring::Error> for Error {
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, thiserror::Error)]
#[error("connection closed before message completed")]
struct Closed;
#[derive(Debug, thiserror::Error)]
#[error("client error (SendRequest)")]
struct SendRequest(#[source] Closed);
#[test]
fn a_failure_is_described_with_the_causes_its_own_text_leaves_out() {
let error = Error::Io(std::io::Error::other(SendRequest(Closed)));
assert_eq!(
error.describe(),
"io: client error (SendRequest): connection closed before message completed"
);
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Language tags: what a container or a file name calls a language, and what a
//! viewer calls it.
/// The languages offered by name where a preference is chosen ahead of any
/// file, as the three-letter tag a Matroska file carries. The same list
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
/// and three-letter forms as one.
pub const LANGUAGES: [&str; 29] = [
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
"gre", "ind", "may",
];
/// Each language's name and the tags it goes by, the [`LANGUAGES`] form first.
///
/// Deliberately a short list rather than a full ISO 639 table: it covers what
/// the shares this app is pointed at actually contain, and an unrecognised tag
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
/// on its own.
const TABLE: [(&str, &[&str]); 29] = [
("English", &["eng", "en"]),
("Japanese", &["jpn", "ja"]),
("German", &["ger", "de", "deu"]),
("French", &["fre", "fr", "fra"]),
("Spanish", &["spa", "es"]),
("Italian", &["ita", "it"]),
("Portuguese", &["por", "pt"]),
("Dutch", &["dut", "nl", "nld"]),
("Russian", &["rus", "ru"]),
("Polish", &["pol", "pl"]),
("Swedish", &["swe", "sv"]),
("Danish", &["dan", "da"]),
("Norwegian", &["nor", "no"]),
("Finnish", &["fin", "fi"]),
("Czech", &["cze", "cs", "ces"]),
("Hungarian", &["hun", "hu"]),
("Turkish", &["tur", "tr"]),
("Arabic", &["ara", "ar"]),
("Hebrew", &["heb", "he"]),
("Hindi", &["hin", "hi"]),
("Korean", &["kor", "ko"]),
("Chinese", &["chi", "zh", "zho"]),
("Thai", &["tha", "th"]),
("Vietnamese", &["vie", "vi"]),
("Ukrainian", &["ukr", "uk"]),
("Romanian", &["rum", "ro", "ron"]),
("Greek", &["gre", "el", "ell"]),
("Indonesian", &["ind", "id"]),
("Malay", &["may", "ms", "msa"]),
];
/// A readable name for a language tag, falling back to the tag itself.
pub fn language_name(tag: &str) -> String {
// Container tags come in both the two- and three-letter forms, and often
// with a region suffix: "en", "eng", "en-US", "pt-BR".
let base = tag.split(['-', '_']).next().unwrap_or(tag);
let Some((name, _)) = TABLE
.iter()
.find(|(_, tags)| tags.iter().any(|known| known.eq_ignore_ascii_case(base)))
else {
return tag.to_string();
};
// The region matters when there is one — Brazilian Portuguese is not
// Portuguese to anyone choosing between the two.
match tag.split_once(['-', '_']) {
Some((_, region)) if !region.is_empty() => {
format!("{name} ({})", region.to_ascii_uppercase())
}
_ => (*name).to_string(),
}
}
/// Whether two container tags name the same language: "en" and "eng" do.
pub fn same_language(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
}
/// The [`LANGUAGES`] tag for one word of a file name, if it names a language:
/// "en", "eng" and "English" are all `"eng"`.
///
/// For the words around a subtitle file's name — `Episode 01.en.srt`,
/// `2_English.srt` — where a two-letter word is far more often a language than
/// anything else.
pub fn language_tag(word: &str) -> Option<&'static str> {
TABLE
.iter()
.find(|(name, tags)| {
name.eq_ignore_ascii_case(word) || tags.iter().any(|tag| tag.eq_ignore_ascii_case(word))
})
.map(|(_, tags)| tags[0])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
assert_eq!(language_name("jpn"), "Japanese");
assert_eq!(language_name("ja"), "Japanese");
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
// An unknown tag is shown as it stands rather than as "Unknown", which
// would make two of them indistinguishable.
assert_eq!(language_name("mis"), "mis");
}
#[test]
fn a_language_named_by_either_tag_or_by_its_name_has_the_tag_a_preference_uses() {
assert_eq!(language_tag("en"), Some("eng"));
assert_eq!(language_tag("ENG"), Some("eng"));
assert_eq!(language_tag("German"), Some("ger"));
assert_eq!(language_tag("deu"), Some("ger"));
assert_eq!(language_tag("forced"), None);
}
#[test]
fn every_language_offered_by_name_is_one_the_table_knows() {
let firsts: Vec<&str> = TABLE.iter().map(|(_, tags)| tags[0]).collect();
let mut offered = LANGUAGES.to_vec();
let mut known = firsts.clone();
offered.sort_unstable();
known.sort_unstable();
assert_eq!(offered, known);
}
}
+2
View File
@@ -13,6 +13,7 @@ pub mod chapters;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod franchise; pub mod franchise;
pub mod language;
pub mod library; pub mod library;
pub mod metadata; pub mod metadata;
pub mod naming; pub mod naming;
@@ -20,6 +21,7 @@ pub mod prefs;
pub mod profile; pub mod profile;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod subtitles;
pub mod sync; pub mod sync;
pub use account::{Account, AccountStore}; pub use account::{Account, AccountStore};
+9
View File
@@ -813,6 +813,15 @@ pub fn is_video_file(name: &str) -> bool {
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) .is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
} }
/// Subtitle formats mpv reads from a file of their own.
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "ssa", "vtt"];
/// Whether a filename names a subtitle file a video can be paired with.
pub fn is_subtitle_file(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, ext)| SUBTITLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+87 -9
View File
@@ -27,11 +27,13 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use proton_drive_rs::{ use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType, Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
}; };
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -973,7 +975,10 @@ impl SharedLibrary {
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.root_node().await?; let root = retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
client.root_node()
})
.await?;
let mut level = vec![root.uid.clone()]; let mut level = vec![root.uid.clone()];
let mut found = vec![root]; let mut found = vec![root];
@@ -981,14 +986,10 @@ impl SharedLibrary {
let listings = level.into_iter().map(|folder| { let listings = level.into_iter().map(|folder| {
let client = client.clone(); let client = client.clone();
async move { async move {
let child_uids: Vec<NodeUid> = retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
client.enumerate_folder_children_node_uids(&folder).await?; list_folder(&client, &folder)
if child_uids.is_empty() { })
return Ok(Vec::new()); .await
}
// Chunked and fanned out inside the SDK too, so this is a
// multiplier on that concurrency rather than the only one.
client.enumerate_nodes(&child_uids).await
} }
}); });
@@ -1010,6 +1011,59 @@ impl SharedLibrary {
} }
} }
/// One folder's children, with their details.
async fn list_folder(
client: &ShareClient,
folder: &NodeUid,
) -> proton_sdk::error::Result<Vec<Node>> {
let child_uids: Vec<NodeUid> = client.enumerate_folder_children_node_uids(folder).await?;
if child_uids.is_empty() {
return Ok(Vec::new());
}
// Chunked and fanned out inside the SDK too, so this is a multiplier on
// that concurrency rather than the only one.
client.enumerate_nodes(&child_uids).await
}
/// Run `operation`, and again after each delay while it fails in a way
/// `retriable` says another attempt could fix.
///
/// A crawl is hundreds of listings, and one that fails ends the crawl. The SDK
/// replays a timeout or a refused connection itself, but not a connection that
/// dropped mid-request — the request may have been applied, which matters for
/// a write and not at all for a listing. A phone sees exactly that: a pooled
/// connection the OS tore down while the app was off screen, or a hand-over
/// from Wi-Fi to mobile data.
async fn retrying<T, E, O, OF>(
delays: &[Duration],
retriable: impl Fn(&E) -> bool,
operation: O,
) -> std::result::Result<T, E>
where
O: Fn() -> OF,
OF: std::future::Future<Output = std::result::Result<T, E>>,
{
let mut delays = delays.iter();
loop {
match operation().await {
Err(error) if retriable(&error) => match delays.next() {
Some(delay) => tokio::time::sleep(*delay).await,
None => return Err(error),
},
done => return done,
}
}
}
/// How long a crawl waits before listing a folder again. About a quarter of a
/// minute in all: long enough for a network to come back, short enough that a
/// share that is really gone still says so.
const CRAWL_RETRY_DELAYS: [Duration; 3] = [
Duration::from_secs(1),
Duration::from_secs(3),
Duration::from_secs(9),
];
/// How many folders of one level to list at once. /// How many folders of one level to list at once.
/// ///
/// Each of these fans out again inside the SDK — up to four detail batches per /// Each of these fans out again inside the SDK — up to four detail batches per
@@ -1087,6 +1141,30 @@ mod tests {
} }
} }
#[tokio::test]
async fn a_listing_that_drops_is_tried_again_until_it_comes_back() {
let calls = AtomicUsize::new(0);
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(2, &calls)).await;
assert_eq!(result, Ok(()));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn a_listing_that_keeps_failing_reports_its_failure_once_the_delays_run_out() {
let calls = AtomicUsize::new(0);
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(9, &calls)).await;
assert_eq!(result, Err(()));
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn a_failure_another_attempt_cannot_fix_is_reported_at_once() {
let calls = AtomicUsize::new(0);
let result = retrying(&[Duration::ZERO; 3], |_| false, flaky(1, &calls)).await;
assert_eq!(result, Err(()));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test] #[tokio::test]
async fn a_share_never_refreshed_is_refreshed_before_the_operation() { async fn a_share_never_refreshed_is_refreshed_before_the_operation() {
let ages = SessionAges::default(); let ages = SessionAges::default();
+202
View File
@@ -0,0 +1,202 @@
//! Which of a file's subtitle tracks is the one to read.
//!
//! Anime releases often carry two subtitle tracks in one language: the
//! dialogue, and a "Signs & Songs" track that only translates the on-screen
//! text and the lyrics. The second is the one marked default or forced as
//! often as not, and mpv, picking by language, plays it — an episode where
//! nobody seems to speak. A language cannot tell the two apart; the track's
//! name and its forced flag can.
//!
//! Nothing here talks to a player. Every client reads the track list out of
//! mpv once a file is loaded and asks [`full_instead`] whether to switch.
use crate::language::same_language;
/// One subtitle track of the open file, as mpv lists it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubtitleTrack {
/// mpv's id for it, what `sid` is set to.
pub id: i64,
pub language: Option<String>,
/// The name the muxer gave it: "Dialogue", "Signs & Songs".
pub title: Option<String>,
/// The container marks it as the default choice.
pub default: bool,
/// The container marks it as forced: on-screen text only, by convention.
pub forced: bool,
/// It is the one playing.
pub selected: bool,
}
/// How much of what is said a track carries, as far as its name and flags say.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Coverage {
/// Signs, songs, or forced: not the dialogue.
Partial,
/// Nothing says either way.
Unknown,
/// Named as the dialogue: "Dialogue", "Full Subtitles", "SDH".
Full,
}
/// Words in a track's name that make it the whole dialogue. They win over the
/// partial ones: "Full (with Signs)" is still the full track.
const FULL_WORDS: [&str; 6] = ["full", "dialogue", "dialog", "dialogues", "sdh", "cc"];
/// Words in a track's name that make it something other than the dialogue.
const PARTIAL_WORDS: [&str; 13] = [
"sign", "signs", "song", "songs", "s&s", "forced", "karaoke", "lyrics", "op", "ed", "oped",
"opening", "ending",
];
impl SubtitleTrack {
pub fn coverage(&self) -> Coverage {
let title = self.title.as_deref().unwrap_or_default().to_lowercase();
let words: Vec<&str> = title
.split(|c: char| !c.is_alphanumeric() && c != '&')
.filter(|word| !word.is_empty())
.collect();
let has = |list: &[&str]| words.iter().any(|word| list.contains(word));
if has(&FULL_WORDS) {
Coverage::Full
} else if self.forced || has(&PARTIAL_WORDS) {
Coverage::Partial
} else {
Coverage::Unknown
}
}
}
/// The track to play in place of the one mpv chose, if it chose the wrong one.
///
/// Only a track in the same language as the playing one is considered — the
/// language is the viewer's choice, and this only corrects which of its tracks
/// was taken. The playing track is replaced when it is partial and the file has
/// one in its language that is not, or when nothing names it and another is
/// named as the dialogue. Among the rest, a track named as the dialogue comes
/// first, then the container's default, then the file's order. `None` leaves
/// the selection as it is, which includes subtitles being off.
pub fn full_instead(tracks: &[SubtitleTrack]) -> Option<i64> {
let playing = tracks.iter().find(|track| track.selected)?;
let current = playing.coverage();
if current == Coverage::Full {
return None;
}
let same = |track: &SubtitleTrack| match (&playing.language, &track.language) {
(Some(a), Some(b)) => same_language(a, b),
(None, None) => true,
_ => false,
};
tracks
.iter()
.filter(|track| track.id != playing.id && same(track))
.filter(|track| track.coverage() > current)
// `max_by_key` keeps the last of equals; reversed, the first.
.rev()
.max_by_key(|track| (track.coverage(), track.default))
.map(|track| track.id)
}
#[cfg(test)]
mod tests {
use super::*;
fn track(id: i64, language: &str, title: &str) -> SubtitleTrack {
SubtitleTrack {
id,
language: Some(language.into()),
title: (!title.is_empty()).then(|| title.into()),
..SubtitleTrack::default()
}
}
fn playing(mut track: SubtitleTrack) -> SubtitleTrack {
track.selected = true;
track
}
#[test]
fn signs_and_songs_playing_gives_way_to_the_dialogue_in_its_language() {
let tracks = [
playing(track(1, "eng", "Signs & Songs")),
track(2, "eng", "Dialogue"),
];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn an_unnamed_track_gives_way_to_one_named_as_the_dialogue() {
let tracks = [playing(track(1, "en", "")), track(2, "en", "Dialogue")];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn a_forced_track_gives_way_to_an_unnamed_one_and_the_default_comes_first() {
let mut forced = playing(track(1, "eng", "English"));
forced.forced = true;
let mut default = track(3, "eng", "");
default.default = true;
let tracks = [forced, track(2, "eng", ""), default];
assert_eq!(full_instead(&tracks), Some(3));
}
#[test]
fn equal_candidates_resolve_to_the_first_in_the_file() {
let tracks = [
playing(track(1, "eng", "Songs")),
track(2, "eng", ""),
track(3, "eng", ""),
];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn a_track_in_another_language_is_never_switched_to() {
let tracks = [
playing(track(1, "eng", "Signs")),
track(2, "ger", "Dialogue"),
];
assert_eq!(full_instead(&tracks), None);
}
#[test]
fn two_letter_and_three_letter_tags_are_one_language() {
let tracks = [playing(track(1, "en", "OP/ED")), track(2, "eng", "")];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn the_dialogue_playing_stays_and_subtitles_off_stay_off() {
let tracks = [
track(1, "eng", "Signs & Songs"),
playing(track(2, "eng", "Dialogue")),
];
assert_eq!(full_instead(&tracks), None);
let off = [
track(1, "eng", "Signs & Songs"),
track(2, "eng", "Dialogue"),
];
assert_eq!(full_instead(&off), None);
}
#[test]
fn a_full_word_outweighs_a_partial_one_in_the_same_name() {
assert_eq!(
track(1, "eng", "Full (with Signs)").coverage(),
Coverage::Full
);
assert_eq!(track(1, "eng", "Signs/Songs").coverage(), Coverage::Partial);
assert_eq!(track(1, "eng", "English").coverage(), Coverage::Unknown);
// A word inside another is not that word.
assert_eq!(
track(1, "eng", "Designer cut").coverage(),
Coverage::Unknown
);
}
#[test]
fn a_partial_track_with_only_partial_company_stays() {
let tracks = [playing(track(1, "eng", "Signs")), track(2, "eng", "Songs")];
assert_eq!(full_instead(&tracks), None);
}
}
+1 -1
View File
@@ -52,4 +52,4 @@ pub use player::{
}; };
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
pub use render::VideoRenderer; pub use render::VideoRenderer;
pub use tracks::{LANGUAGES, Track, TrackKind, language_name, pick_track}; pub use tracks::{LANGUAGES, Track, TrackKind, full_subtitles_instead, language_name, pick_track};
+22
View File
@@ -342,6 +342,28 @@ impl Player {
Ok(handle) Ok(handle)
} }
/// Add a subtitle file to the one playing, read through the same
/// registry as the video, so mpv never learns where either came from.
///
/// Only once the video has loaded: mpv attaches an external track to the
/// file open now. `select` turns it on; otherwise it waits in the list.
/// The handle keeps it readable, like [`Self::play`]'s.
pub fn add_subtitle(
&self,
stream: VideoStream,
title: &str,
language: Option<&str>,
select: bool,
) -> Result<StreamHandle> {
let handle = self.registry.publish(stream);
let url = handle.url();
let flag = if select { "select" } else { "auto" };
let mut args = vec![url.as_str(), flag, title];
args.extend(language);
self.mpv.command("sub-add", &args)?;
Ok(handle)
}
/// Start playing a stream `seconds` in. /// Start playing a stream `seconds` in.
/// ///
/// Through mpv's `start` option rather than a seek once the file has /// Through mpv's `start` option rather than a seek once the file has
+23 -80
View File
@@ -12,6 +12,7 @@
//! an empty string. //! an empty string.
use libmpv2::Mpv; use libmpv2::Mpv;
use pstr_core::subtitles::{SubtitleTrack, full_instead};
/// What a track carries. /// What a track carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -69,6 +70,9 @@ pub struct Track {
pub selected: bool, pub selected: bool,
/// Whether the container marks it as the default choice. /// Whether the container marks it as the default choice.
pub default: bool, pub default: bool,
/// Whether the container marks it as forced: a subtitle track of the
/// on-screen text alone.
pub forced: bool,
/// True for a track added from outside the file — a sidecar subtitle. None /// True for a track added from outside the file — a sidecar subtitle. None
/// of ours are, yet. /// of ours are, yet.
pub external: bool, pub external: bool,
@@ -152,9 +156,22 @@ pub fn pick_track(
) )
} }
/// Whether two container tags name the same language: "en" and "eng" do. /// The dialogue track to play in place of the subtitle track mpv chose, when
fn same_language(a: &str, b: &str) -> bool { /// mpv chose signs and songs; see [`pstr_core::subtitles::full_instead`].
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b) pub fn full_subtitles_instead(tracks: &[Track]) -> Option<i64> {
let subtitles: Vec<_> = tracks
.iter()
.filter(|track| track.kind == TrackKind::Subtitle)
.map(|track| SubtitleTrack {
id: track.id,
language: track.language.clone(),
title: track.title.clone(),
default: track.default,
forced: track.forced,
selected: track.selected,
})
.collect();
full_instead(&subtitles)
} }
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> { fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
@@ -172,6 +189,7 @@ fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
codec: text(mpv, index, "codec"), codec: text(mpv, index, "codec"),
selected: flag(mpv, index, "selected"), selected: flag(mpv, index, "selected"),
default: flag(mpv, index, "default"), default: flag(mpv, index, "default"),
forced: flag(mpv, index, "forced"),
external: flag(mpv, index, "external"), external: flag(mpv, index, "external"),
}) })
} }
@@ -190,73 +208,7 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// The languages offered by name where a preference is chosen ahead of any pub use pstr_core::language::{LANGUAGES, language_name, same_language};
/// file, as the three-letter tag a Matroska file carries. The same list
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
/// and three-letter forms as one.
pub const LANGUAGES: [&str; 29] = [
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
"gre", "ind", "may",
];
/// A readable name for a language tag, falling back to the tag itself.
///
/// Deliberately a short list rather than a full ISO 639 table: it covers what
/// the shares this app is pointed at actually contain, and an unrecognised tag
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
/// on its own.
pub fn language_name(tag: &str) -> String {
// Container tags come in both the two- and three-letter forms, and often
// with a region suffix: "en", "eng", "en-US", "pt-BR".
let base = tag
.split(['-', '_'])
.next()
.unwrap_or(tag)
.to_ascii_lowercase();
let name = match base.as_str() {
"en" | "eng" => "English",
"ja" | "jpn" => "Japanese",
"de" | "ger" | "deu" => "German",
"fr" | "fre" | "fra" => "French",
"es" | "spa" => "Spanish",
"it" | "ita" => "Italian",
"pt" | "por" => "Portuguese",
"nl" | "dut" | "nld" => "Dutch",
"ru" | "rus" => "Russian",
"pl" | "pol" => "Polish",
"sv" | "swe" => "Swedish",
"da" | "dan" => "Danish",
"no" | "nor" => "Norwegian",
"fi" | "fin" => "Finnish",
"cs" | "cze" | "ces" => "Czech",
"hu" | "hun" => "Hungarian",
"tr" | "tur" => "Turkish",
"ar" | "ara" => "Arabic",
"he" | "heb" => "Hebrew",
"hi" | "hin" => "Hindi",
"ko" | "kor" => "Korean",
"zh" | "chi" | "zho" => "Chinese",
"th" | "tha" => "Thai",
"vi" | "vie" => "Vietnamese",
"uk" | "ukr" => "Ukrainian",
"ro" | "rum" | "ron" => "Romanian",
"el" | "gre" | "ell" => "Greek",
"id" | "ind" => "Indonesian",
"ms" | "may" | "msa" => "Malay",
_ => return tag.to_string(),
};
// The region matters when there is one — Brazilian Portuguese is not
// Portuguese to anyone choosing between the two.
match tag.split_once(['-', '_']) {
Some((_, region)) if !region.is_empty() => {
format!("{name} ({})", region.to_ascii_uppercase())
}
_ => name.to_string(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -271,6 +223,7 @@ mod tests {
codec: None, codec: None,
selected: false, selected: false,
default: false, default: false,
forced: false,
external: false, external: false,
} }
} }
@@ -292,16 +245,6 @@ mod tests {
assert_eq!(sub.label(), "English — Signs & Songs [ass]"); assert_eq!(sub.label(), "English — Signs & Songs [ass]");
} }
#[test]
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
assert_eq!(language_name("jpn"), "Japanese");
assert_eq!(language_name("ja"), "Japanese");
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
// An unknown tag is shown as it stands rather than as "Unknown", which
// would make two of them indistinguishable.
assert_eq!(language_name("mis"), "mis");
}
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track { fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
Track { Track {
language: language.map(Into::into), language: language.map(Into::into),
+12
View File
@@ -7,6 +7,18 @@ screens, while UniFFI exposes the existing catalog, share, streaming, watch-stat
and offline logic. Keep reusable behavior in the Rust crates rather than and offline logic. Keep reusable behavior in the Rust crates rather than
reimplementing it in Kotlin. reimplementing it in Kotlin.
## Android TV
The same build installs on Android TV: it is listed in the TV launcher
(`LEANBACK_LAUNCHER`, with the banner `scripts/render-tv-banner.sh` renders)
and requires neither a touch screen nor leanback. Every screen is Compose
`clickable`s, which the D-pad already moves between. The player takes a
remote's keys while its controls are down: left and right seek ten seconds,
the middle button brings the controls up, and play/pause works as on a
keyboard. There is no TV-specific layout yet, and none of it has been run on a
TV. Signing in to an account, so that shares arrive through sync, is the way
to set one up without typing a share link on a remote.
## Toolchain ## Toolchain
Install JDK 17, Android SDK Platform/Build Tools 36, NDK Install JDK 17, Android SDK Platform/Build Tools 36, NDK
+21
View File
@@ -145,6 +145,27 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B65 — the desktop app quit at start on a variable CJK font
**Symptom.** On Secureblue (Fedora Atomic) the window showed for a moment and
the process panicked: `Error parsing "system-cjk-fallback" TTF/OTF font file:
Invalid index 262144 for font collection`
([#1](https://github.com/narrrl/proton-stream/issues/1)).
**Cause.** The CJK fallback is whatever `fc-match "Noto Sans CJK JP"` names,
with the face taken from `%{index}`. For a variable font fontconfig packs the
named instance into that number's high 16 bits; Fedora ships Noto Sans CJK as a
variable font, so it answered 262144 — instance 4 of face 0. egui took it as a
face index, found no such face, and panics on a font it cannot parse.
**Fix.** `theme.rs` keeps only the low 16 bits, the face. And the fallback is
parsed with skrifa, as egui parses it, before it is handed over; one that fails
is logged and left out, so no installed font can stop the app again.
**Verified.** Workspace gate, with
`a_variable_fonts_named_instance_is_not_taken_for_a_face` and
`a_fallback_font_egui_cannot_read_is_left_out`. Not yet run on Secureblue.
### B64 — Android offline downloads looked stuck ### B64 — Android offline downloads looked stuck
**Symptom.** A queued episode, and more so a queued season, sat at a progress **Symptom.** A queued episode, and more so a queued season, sat at a progress
+15 -10
View File
@@ -15,10 +15,7 @@
## Build order from here ## Build order from here
1. **Sidecar subtitle files** — see the "not done" note at the end of this 1. Packaging is in place (`packaging/README.md`); what is left there is running
section. The work is in `pstr-core`: crawl them, and pair them to a video by
name.
2. Packaging is in place (`packaging/README.md`); what is left there is running
the macOS half on a real host, and a release workflow that drives every the macOS half on a real host, and a release workflow that drives every
platform. platform.
@@ -151,12 +148,20 @@ Note the glyph trap: egui's bundled fonts cover the transport icons
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new (`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
on a button wants checking on screen before it ships. on a button wants checking on screen before it ships.
Not done: **sidecar subtitle files**. A `.srt`/`.ass` sitting beside the video in **Sidecar subtitle files.** A `.srt`, `.ass`, `.ssa` or `.vtt` beside a video
the share is invisible to all of this — the crawl stores video files only, so is paired with it by name at crawl time (`catalog::build_sidecars`): the video's
there is nothing to offer in the menu. The player side is nearly free once the name followed by a dot or nothing (`S01E01.en.forced.ass`), the same inside a
catalog has them (`sub-add pstr://<token>` through the same registry, since mpv `Subs` folder beside the videos, the RARBG layout `Subs/<video>/2_English.srt`,
reads an external subtitle through the same protocol); the work is in and failing those the only video in the folder. The words past the video's
`pstr-core`: crawl them, and pair them to a video by name. name give the language and the picker's title. They are stored in `sidecars`
(schema v12), rebuilt by every crawl like `nodes`.
Each client opens them while mpv opens the video and adds them with `sub-add
pstr://<token>` through the same registry once mpv reports the file loaded. One
is turned on only when subtitles are on and the file has none of its own
playing, and only in the preferred language (`catalog::preferred_sidecar`); the
rest wait in the subtitle menu. A downloaded episode played without a session
goes without them, and they are not downloaded with it.
## Design decisions already taken ## Design decisions already taken
+6
View File
@@ -16,6 +16,7 @@ file of the same name is the one to change with it.
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` | | `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` | | `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
| `download/*` | `Download/*` | | `download/*` | `Download/*` |
| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` |
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` | | `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` | | `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
@@ -79,6 +80,11 @@ Each of these is the platform's doing, not a choice to diverge.
place of Android's `WatchSyncWorker`, and every five minutes while it is open. place of Android's `WatchSyncWorker`, and every five minutes while it is open.
- **Background audio off pauses** the episode when the app leaves the screen, where - **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it. Android stops it.
- **New-episode checks run when iOS chooses.** Android's `NewEpisodesWorker`
crawls every six hours on a network. iOS's `BGProcessingTask` is asked for no
sooner than six hours, and the system decides when, mostly overnight on a
charger. An app refresh would come more often but gets about thirty seconds,
which a crawl of a large share does not fit in.
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's - **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as `.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`) a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
+6
View File
@@ -152,10 +152,16 @@ final class AppModel {
} }
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile. /// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
///
/// Held under a background task: a crawl takes minutes, and without one
/// iOS suspends the app the moment it leaves the screen and tears down its
/// connections, so a glance at another app failed the crawl.
private func crawlShares(_ id: String?) async throws { private func crawlShares(_ id: String?) async throws {
crawlsRunning += 1 crawlsRunning += 1
crawling = true crawling = true
let task = BackgroundTask(name: "crawl")
defer { defer {
task.end()
crawlsRunning -= 1 crawlsRunning -= 1
crawling = crawlsRunning > 0 crawling = crawlsRunning > 0
} }
+82
View File
@@ -0,0 +1,82 @@
import BackgroundTasks
import Foundation
import UserNotifications
/// Look at the shares while the app is away, and say when a show gained
/// episodes — what `NewEpisodesWorker` does on Android.
///
/// A processing task rather than an app refresh: a crawl of a large share
/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it
/// when it sees fit, mostly overnight on a charger, so it is a best effort
/// where Android's is a schedule.
enum NewEpisodes {
static let identifier = "io.narl.protonstream.new-episodes"
private static let interval: TimeInterval = 6 * 60 * 60
/// At launch, before the app finishes launching: iOS refuses a handler
/// registered later.
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
guard let task = task as? BGProcessingTask else { return }
run(task)
}
}
/// Keep the next look scheduled, or cancel it when the viewer turned it off.
static func schedule() {
guard SettingsStore().newEpisodeNotifications else {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier)
return
}
let request = BGProcessingTaskRequest(identifier: identifier)
request.requiresNetworkConnectivity = true
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
try? BGTaskScheduler.shared.submit(request)
}
/// Ask once, when the viewer has the setting on; iOS remembers the answer.
static func requestPermission() {
guard SettingsStore().newEpisodeNotifications else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
private static func run(_ task: BGProcessingTask) {
schedule()
let work = Task {
let news = (try? await NativeRuntime.engine().crawlForNews()) ?? []
await post(news)
task.setTaskCompleted(success: !Task.isCancelled)
}
task.expirationHandler = {
work.cancel()
Task { try? await NativeRuntime.engine().stopCrawl() }
}
}
private static func post(_ news: [NewEpisodesRecord]) async {
let center = UNUserNotificationCenter.current()
guard await center.notificationSettings().authorizationStatus == .authorized else { return }
for item in news {
let content = UNMutableNotificationContent()
content.title = item.title
content.body = describe(item)
content.threadIdentifier = "new-episodes"
// Keyed by the show, so a second look's news replaces the first.
let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil)
try? await center.add(request)
}
}
/// What one show's notification says: "S01E04 and 2 more are new".
static func describe(_ news: NewEpisodesRecord) -> String {
let more = Int(news.count) - 1
guard let first = news.first else {
return news.count == 1 ? "1 new episode" : "\(news.count) new episodes"
}
switch more {
case 0: return "\(first) is new"
case 1: return "\(first) and 1 more are new"
default: return "\(first) and \(more) more are new"
}
}
}
@@ -33,6 +33,13 @@ struct ProtonStreamApp: App {
} }
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NewEpisodes.register()
NewEpisodes.schedule()
NewEpisodes.requestPermission()
return true
}
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask { func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask } MainActor.assumeIsolated { OrientationLock.mask }
} }
+7
View File
@@ -72,6 +72,12 @@ struct SettingsStore {
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) } nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
} }
/// Whether a background crawl may say that a show gained episodes.
var newEpisodeNotifications: Bool {
get { bool(Key.newEpisodeNotifications, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
}
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded] static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity] private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
@@ -88,5 +94,6 @@ struct SettingsStore {
static let libraryFilter = "library_filter" static let libraryFilter = "library_filter"
static let libraryGrouped = "library_grouped" static let libraryGrouped = "library_grouped"
static let metadataOfferDismissed = "metadata_offer_dismissed" static let metadataOfferDismissed = "metadata_offer_dismissed"
static let newEpisodeNotifications = "new_episode_notifications"
} }
} }
+126 -2
View File
@@ -33,6 +33,10 @@ struct MpvTrack: Identifiable, Hashable {
let language: String let language: String
let title: String let title: String
let selected: Bool let selected: Bool
/// The container marks it as the default choice.
var isDefault = false
/// The container marks it as forced: on-screen text only, by convention.
var forced = false
var label: String { var label: String {
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
@@ -91,6 +95,8 @@ final class MpvPlayer {
/// Called when the duration or the pause state changes: what the lock /// Called when the duration or the pause state changes: what the lock
/// screen shows, and whether the screen may sleep. /// screen shows, and whether the screen may sleep.
@ObservationIgnored var onStateChanged: (() -> Void)? @ObservationIgnored var onStateChanged: (() -> Void)?
/// Called once per file, when mpv has loaded it and picked its tracks.
@ObservationIgnored var onLoaded: (() -> Void)?
@ObservationIgnored let layer = MpvMetalLayer() @ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer? @ObservationIgnored private var mpv: OpaquePointer?
@@ -99,6 +105,22 @@ final class MpvPlayer {
/// `END_FILE(stop)` that stopping it emits. /// `END_FILE(stop)` that stopping it emits.
@ObservationIgnored private var loading = false @ObservationIgnored private var loading = false
@ObservationIgnored private var pendingMedia: String? @ObservationIgnored private var pendingMedia: String?
/// The load generation whose file mpv has loaded.
@ObservationIgnored private var loadedGeneration = -1
/// Subtitle files handed over before their file loaded, and whether each
/// is the one to turn on.
@ObservationIgnored private var pendingSubtitles: [(SubtitleFileRecord, Bool)] = []
/// The drawable size mpv last built its output for; see `outputResized`.
@ObservationIgnored private var outputSize: CGSize = .zero
/// Off while the app is in the background, where a refit must not bring
/// the picture back.
@ObservationIgnored private var videoEnabled = true
@ObservationIgnored private var refitTask: Task<Void, Never>?
/// Every `vid` write, in the order they were made. Each one tears down or
/// builds mpv's video output and waits on its render thread, which the
/// main thread must not; and a refit's "auto" must never land after the
/// background's "no".
@ObservationIgnored private let videoQueue = DispatchQueue(label: "mpv-video")
/// A core, or nil when libmpv would not start. /// A core, or nil when libmpv would not start.
static func create() -> MpvPlayer? { static func create() -> MpvPlayer? {
@@ -209,6 +231,9 @@ final class MpvPlayer {
problem = nil problem = nil
self.media = nil self.media = nil
generation += 1 generation += 1
// Subtitles for the file being replaced will never be added.
pendingSubtitles.forEach { pstr_android_stream_release($0.0.handle) }
pendingSubtitles = []
pendingMedia = media pendingMedia = media
loading = true loading = true
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 { if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
@@ -219,6 +244,46 @@ final class MpvPlayer {
return true return true
} }
/// Add the subtitle files beside the file of load `generation`.
///
/// Held until mpv has that file loaded, since an external track belongs to
/// the file open at the time, and released instead if another file has
/// been loaded since. `files.preferred` is turned on only when the file
/// has no subtitle of its own playing; `subtitles` false turns none on.
func addSubtitles(generation: Int, files: SubtitleFilesRecord, subtitles: Bool) {
let marked = files.files.enumerated().map { index, file in
(file, subtitles && files.preferred == UInt32(index))
}
if generation != self.generation {
marked.forEach { pstr_android_stream_release($0.0.handle) }
} else if loadedGeneration == generation {
attach(marked)
} else {
pendingSubtitles += marked
}
}
/// `sub-add` off the main thread: mpv reads the whole file before it
/// returns, through the stream protocol and so from the network.
private func attach(_ subtitles: [(SubtitleFileRecord, Bool)]) {
guard let mpv, !subtitles.isEmpty else { return }
let showing = getString("sid").map { $0 != "no" } ?? false
let core = UncheckedCore(mpv)
DispatchQueue.global(qos: .userInitiated).async {
for (file, select) in subtitles {
var args = ["sub-add", "pstr://\(file.handle)", select && !showing ? "select" : "auto", file.title]
if let language = file.language { args.append(language) }
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
pointers.append(nil)
let status = mpv_command(core.pointer, &pointers)
pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) }
// 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 status < 0 { pstr_android_stream_release(file.handle) }
}
}
}
/// Written through rather than left to the observer: a tap that only shows /// Written through rather than left to the observer: a tap that only shows
/// its effect on mpv's next property event reads as a button that missed. /// its effect on mpv's next property event reads as a button that missed.
func setPaused(_ value: Bool) { func setPaused(_ value: Bool) {
@@ -243,7 +308,42 @@ final class MpvPlayer {
/// Showing no picture while backgrounded keeps the audio playing: iOS /// Showing no picture while backgrounded keeps the audio playing: iOS
/// refuses GPU work from a background app, and would end the process for it. /// refuses GPU work from a background app, and would end the process for it.
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") } func setVideoEnabled(_ enabled: Bool) {
videoEnabled = enabled
setVideo([enabled ? "auto" : "no"])
}
/// The layer's drawable changed size: a rotation, a Split View resize.
///
/// MPVKit's MoltenVK context reads `drawableSize` only when mpv builds its
/// output and never hears of a resize after that (mpvkit/MPVKit#3), so mpv
/// went on drawing a frame of the old size into the corner of the new
/// drawable. Turning the picture off and on builds the output again at the
/// new size — the same round trip the background makes, at the cost of a
/// refresh seek. Once the size has settled, so a Split View drag rebuilds
/// it once rather than per step.
func outputResized() {
refitTask?.cancel()
refitTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, let self else { return }
let size = layer.drawableSize
// Only an output that exists and is the wrong size: nothing to fit
// while a file loads, and the background's "no" stays.
guard videoEnabled, videoWidth > 0, outputSize != .zero, size != outputSize,
size.width > 1, size.height > 1 else { return }
outputSize = size
setVideo(["no", "auto"])
}
}
private func setVideo(_ values: [String]) {
guard let mpv else { return }
let core = UncheckedCore(mpv)
videoQueue.async {
for value in values { mpv_set_property_string(core.pointer, "vid", value) }
}
}
/// Stop the open file. The stream behind it is released by mpv closing it, /// Stop the open file. The stream behind it is released by mpv closing it,
/// which this causes; releasing it from here would race that close. /// which this causes; releasing it from here would race that close.
@@ -266,6 +366,7 @@ final class MpvPlayer {
case loaded case loaded
case ended(Int64) case ended(Int64)
case restarted case restarted
case reconfigured
case log(prefix: String, text: String) case log(prefix: String, text: String)
} }
@@ -297,6 +398,8 @@ final class MpvPlayer {
decoded = .loaded decoded = .loaded
case MPV_EVENT_PLAYBACK_RESTART: case MPV_EVENT_PLAYBACK_RESTART:
decoded = .restarted decoded = .restarted
case MPV_EVENT_VIDEO_RECONFIG:
decoded = .reconfigured
case MPV_EVENT_END_FILE: case MPV_EVENT_END_FILE:
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee { if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
decoded = .ended(Int64(end.reason.rawValue)) decoded = .ended(Int64(end.reason.rawValue))
@@ -370,8 +473,19 @@ final class MpvPlayer {
case .loaded: case .loaded:
refreshTracks() refreshTracks()
refreshChapters() refreshChapters()
// Mid-load, this is the outgoing file finishing its load.
if !loading {
loadedGeneration = generation
let ready = pendingSubtitles
pendingSubtitles = []
attach(ready)
onLoaded?()
}
case .restarted: case .restarted:
seeking = false seeking = false
case .reconfigured:
// mpv has just read the drawable to size its output by.
outputSize = layer.drawableSize
case let .ended(reason): case let .ended(reason):
// The outgoing file's END_FILE arrives after the next load began. // The outgoing file's END_FILE arrives after the next load began.
if loading { return } if loading { return }
@@ -404,7 +518,9 @@ final class MpvPlayer {
type: getString(base + "type") ?? "", type: getString(base + "type") ?? "",
language: getString(base + "lang") ?? "", language: getString(base + "lang") ?? "",
title: getString(base + "title") ?? "", title: getString(base + "title") ?? "",
selected: getString(base + "selected") == "yes" selected: getString(base + "selected") == "yes",
isDefault: getString(base + "default") == "yes",
forced: getString(base + "forced") == "yes"
) )
} }
} }
@@ -464,3 +580,11 @@ final class MpvPlayer {
return String(cString: raw) return String(cString: raw)
} }
} }
/// mpv's handle, for a command issued off the main thread. libmpv's client API
/// is thread-safe, and the core is only destroyed when it shuts down, which
/// nothing in the app asks it to.
private struct UncheckedCore: @unchecked Sendable {
let pointer: OpaquePointer
init(_ pointer: OpaquePointer) { self.pointer = pointer }
}
+23
View File
@@ -256,6 +256,7 @@ final class PlayerHost {
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) } player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
player?.onTick = { [weak self] in self?.tick() } player?.onTick = { [weak self] in self?.tick() }
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() } player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
player?.onLoaded = { [weak self] in self?.preferFullSubtitles() }
} }
guard let player else { guard let player else {
playbackError = "Playback is unavailable: libmpv did not start." playbackError = "Playback is unavailable: libmpv did not start."
@@ -305,6 +306,16 @@ final class PlayerHost {
player.setMuted(global.muted) player.setMuted(global.muted)
player.setSpeed(global.speed) player.setSpeed(global.speed)
player.setPaused(false) player.setPaused(false)
// While mpv opens the episode; the player holds them until it has.
let generation = player.generation
let subtitleLanguage = show?.subtitleLanguage ?? global.subtitleLanguage
let subtitles = show?.subtitles ?? global.subtitles
Task {
guard let files = try? await NativeRuntime.engine().openSubtitles(
shareId: episode.shareId, linkId: episode.linkId, language: subtitleLanguage
) else { return }
player.addSubtitles(generation: generation, files: files, subtitles: subtitles)
}
startSaving() startSaving()
} catch { } catch {
if Task.isCancelled || self.session !== session { if Task.isCancelled || self.session !== session {
@@ -423,6 +434,18 @@ final class PlayerHost {
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil) chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
} }
/// Play the dialogue rather than the signs and songs mpv picked by
/// language (`pstr_core::subtitles`). Not remembered: it is the choice mpv
/// should have made, not the viewer's.
private func preferFullSubtitles() {
guard let player, playingOpenEpisode else { return }
let subtitles = player.tracks.filter { $0.type == "sub" }.map {
SubtitleTrackRecord(id: $0.id, language: $0.language, title: $0.title, default: $0.isDefault, forced: $0.forced, selected: $0.selected)
}
guard let id = fullSubtitlesInstead(tracks: subtitles), let track = player.tracks.first(where: { $0.type == "sub" && $0.id == id }) else { return }
player.select(type: "sub", track: track)
}
/// The skip button's offer at the current position, if any. /// The skip button's offer at the current position, if any.
var offer: SkipOffer? { var offer: SkipOffer? {
guard let player, playingOpenEpisode else { return nil } guard let player, playingOpenEpisode else { return nil }
+7 -3
View File
@@ -724,16 +724,18 @@ struct MpvView: UIViewRepresentable {
let player: MpvPlayer let player: MpvPlayer
func makeUIView(context _: Context) -> LayerView { func makeUIView(context _: Context) -> LayerView {
LayerView(layer: player.layer) LayerView(player: player)
} }
func updateUIView(_: LayerView, context _: Context) {} func updateUIView(_: LayerView, context _: Context) {}
final class LayerView: UIView { final class LayerView: UIView {
private let player: MpvPlayer
private let video: CAMetalLayer private let video: CAMetalLayer
init(layer video: CAMetalLayer) { init(player: MpvPlayer) {
self.video = video self.player = player
video = player.layer
super.init(frame: .zero) super.init(frame: .zero)
backgroundColor = .black backgroundColor = .black
video.removeFromSuperlayer() video.removeFromSuperlayer()
@@ -759,6 +761,8 @@ struct MpvView: UIViewRepresentable {
video.contentsScale = scale video.contentsScale = scale
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale) video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
CATransaction.commit() CATransaction.commit()
// A new drawable is not enough on its own: mpv has to be told.
player.outputResized()
} }
override func didMoveToWindow() { override func didMoveToWindow() {
+9
View File
@@ -162,6 +162,7 @@ struct SettingsView: View {
} }
SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding) SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding)
SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio) SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio)
SwitchRow(headline: "New episode notifications", supporting: "Looks at your shares now and then and says when a show gains episodes", isOn: $settings.newEpisodeNotifications)
} }
@ViewBuilder private var storage: some View { @ViewBuilder private var storage: some View {
@@ -264,12 +265,20 @@ private final class SettingsValues {
private let store = SettingsStore() private let store = SettingsStore()
var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } } var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } }
var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } } var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } }
var newEpisodeNotifications: Bool {
didSet {
store.newEpisodeNotifications = newEpisodeNotifications
NewEpisodes.schedule()
NewEpisodes.requestPermission()
}
}
var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } } var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } } var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
init() { init() {
wifiOnly = store.wifiOnly wifiOnly = store.wifiOnly
backgroundAudio = store.backgroundAudio backgroundAudio = store.backgroundAudio
newEpisodeNotifications = store.newEpisodeNotifications
hardwareDecoding = store.hardwareDecoding hardwareDecoding = store.hardwareDecoding
cacheBudgetGib = store.cacheBudgetGib cacheBudgetGib = store.cacheBudgetGib
} }
+3 -1
View File
@@ -64,7 +64,9 @@ targets:
CFBundleDisplayName: Proton Stream CFBundleDisplayName: Proton Stream
LSRequiresIPhoneOS: true LSRequiresIPhoneOS: true
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
UIBackgroundModes: [audio] UIBackgroundModes: [audio, processing]
BGTaskSchedulerPermittedIdentifiers:
- io.narl.protonstream.new-episodes
# The Proton palette's page colour, so a dark app does not open on white. # The Proton palette's page colour, so a dark app does not open on white.
UILaunchScreen: UILaunchScreen:
UIColorName: LaunchBackground UIColorName: LaunchBackground
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=1.5.0 pkgver=1.6.0
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Render the Android TV launcher banner, 320 by 180 dp, from the iOS icon: its
# canvas widened to 16:9 with the face gradient run across the whole width,
# and the play mark a little smaller, since a banner is shown as is rather
# than cut down by a mask. Twice the size, in drawable-nodpi: one file for
# every screen, which the launcher scales to its slot, sharp on a 4K one.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
icon="$script_dir/../ios/ProtonStream/Resources/AppIcon.svg"
out="$script_dir/../android/app/src/main/res/drawable-nodpi/tv_banner.png"
command -v rsvg-convert >/dev/null || { echo "rsvg-convert is required (librsvg)" >&2; exit 1; }
command -v magick >/dev/null || { echo "magick is required (ImageMagick)" >&2; exit 1; }
mkdir -p "$(dirname "$out")"
sed -e 's/width="1024" height="1024" viewBox="0 0 108 108"/width="320" height="180" viewBox="-42 0 192 108"/' \
-e 's/<rect width="108" height="108"/<rect x="-42" width="192" height="108"/' \
-e 's/x1="0" y1="0" x2="108" y2="108"/x1="-42" y1="0" x2="150" y2="108"/' \
-e 's/scale(1.5)/scale(1.15)/' \
"$icon" | rsvg-convert -w 640 -h 360 | magick - -alpha off -strip "$out"
echo "wrote $out"