mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-10 17:27:03 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0be9e3c8 | ||
|
|
4a3a7291b8 | ||
|
|
ab7f057afe | ||
|
|
b1ea076ea3 | ||
|
|
8d615579e2 | ||
|
|
cafd969567 |
No files matched your search
Generated
+14
-13
@@ -4844,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pgp"
|
name = "pgp"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead 0.5.2",
|
"aead 0.5.2",
|
||||||
"aes 0.8.4",
|
"aes 0.8.4",
|
||||||
@@ -5218,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.7.3"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f"
|
checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.0",
|
"base64 0.23.0",
|
||||||
@@ -5245,9 +5245,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.7.3"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0"
|
checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5274,11 +5274,12 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5298,7 +5299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5332,7 +5333,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5351,7 +5352,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5371,7 +5372,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -5384,7 +5385,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -5399,7 +5400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
+3
-3
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.5.1"
|
version = "1.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.7.3" }
|
proton-sdk = { version = "0.9.0" }
|
||||||
proton-drive-rs = { version = "0.7.3" }
|
proton-drive-rs = { version = "0.9.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
|
|||||||
@@ -211,6 +211,9 @@ 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.
|
/// A subtitle file published for mpv, and what to call it.
|
||||||
@@ -428,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);
|
||||||
@@ -436,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 + ']';
|
||||||
}
|
}
|
||||||
@@ -602,6 +611,7 @@ class Player {
|
|||||||
// Mid-load, this is the outgoing file finishing its load.
|
// Mid-load, this is the outgoing file finishing its load.
|
||||||
if (!state_.loading) {
|
if (!state_.loading) {
|
||||||
loaded_generation_ = state_.generation;
|
loaded_generation_ = state_.generation;
|
||||||
|
state_.loaded = true;
|
||||||
ready.swap(pending_subtitles_);
|
ready.swap(pending_subtitles_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,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);
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ 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.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
|
||||||
@@ -108,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" }
|
||||||
@@ -129,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
|
||||||
@@ -347,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()
|
||||||
@@ -384,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 }
|
||||||
@@ -467,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
|
||||||
@@ -476,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")
|
||||||
|
|||||||
@@ -27,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,
|
||||||
@@ -739,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)]
|
||||||
|
|||||||
@@ -578,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),
|
||||||
@@ -735,6 +744,16 @@ 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.
|
/// 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
|
/// One is turned on only when subtitles are on and the video has none of its
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
|
|||||||
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
|
/// 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
|
/// instance is not a face, and passing it to egui as one stopped the app at
|
||||||
/// start.
|
/// start.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
fn fontconfig_face(index: u32) -> u32 {
|
fn fontconfig_face(index: u32) -> u32 {
|
||||||
index & 0xFFFF
|
index & 0xFFFF
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
||||||
use proton_sdk::api::HumanVerificationCredential;
|
use proton_sdk::account::KeyPassphrases;
|
||||||
|
use proton_sdk::api::{HumanVerificationCredential, ResponseCode};
|
||||||
use proton_sdk::error::ProtonError;
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
@@ -51,6 +52,12 @@ const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security
|
|||||||
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
|
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
|
||||||
with its code.";
|
with its code.";
|
||||||
|
|
||||||
|
/// Why a sign-in Proton answered with [`ResponseCode::Blocked`] failed. The
|
||||||
|
/// server's own text says the app version is no longer supported, which is
|
||||||
|
/// not true and sends the viewer looking for an update that does not exist.
|
||||||
|
const SIGN_IN_BLOCKED: &str = "Proton has blocked sign-ins from this app for now. Wait a while \
|
||||||
|
and try again, or sign in once on proton.me first.";
|
||||||
|
|
||||||
/// Blocks in flight for streaming from the account, as for a public link —
|
/// Blocks in flight for streaming from the account, as for a public link —
|
||||||
/// see `ShareStore::open`.
|
/// see `ShareStore::open`.
|
||||||
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
||||||
@@ -91,10 +98,10 @@ impl StoredSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unlock(&self) -> HashMap<String, Vec<u8>> {
|
fn unlock(&self) -> KeyPassphrases {
|
||||||
self.key_passphrases
|
self.key_passphrases
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
|
.map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,9 +431,9 @@ impl AccountStore {
|
|||||||
/// back the account.
|
/// back the account.
|
||||||
///
|
///
|
||||||
/// The key passphrases are derived here, while the token still has the scope
|
/// The key passphrases are derived here, while the token still has the scope
|
||||||
/// the key salts need. Loading the addresses is what proves the mailbox
|
/// the key salts need. Unlocking the user keys is what proves the mailbox
|
||||||
/// password: it unlocks the user keys, and a wrong password unlocks none of
|
/// password: a wrong one unlocks none of them. Only the passphrases are
|
||||||
/// them. Only the passphrases are stored; the password goes no further.
|
/// stored; the password goes no further.
|
||||||
async fn finish(
|
async fn finish(
|
||||||
secrets: Arc<dyn SecretStore>,
|
secrets: Arc<dyn SecretStore>,
|
||||||
session: ProtonApiSession,
|
session: ProtonApiSession,
|
||||||
@@ -435,10 +442,10 @@ async fn finish(
|
|||||||
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||||
probe
|
probe
|
||||||
.account()
|
.account()
|
||||||
.addresses()
|
.unlock()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| match error {
|
.map_err(|error| match error {
|
||||||
ProtonError::InvalidOperation(_) => {
|
ProtonError::KeysLocked => {
|
||||||
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||||
}
|
}
|
||||||
other => other.into(),
|
other => other.into(),
|
||||||
@@ -447,10 +454,10 @@ async fn finish(
|
|||||||
.account()
|
.account()
|
||||||
.key_passphrases()
|
.key_passphrases()
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|(id, passphrase)| {
|
.map(|(id, passphrase)| {
|
||||||
String::from_utf8(passphrase)
|
std::str::from_utf8(passphrase)
|
||||||
.map(|passphrase| (id, passphrase))
|
.map(|passphrase| (id.to_owned(), passphrase.to_owned()))
|
||||||
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
||||||
})
|
})
|
||||||
.collect::<Result<HashMap<_, _>>>()?;
|
.collect::<Result<HashMap<_, _>>>()?;
|
||||||
@@ -500,7 +507,8 @@ fn persist_refreshed_tokens(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
|
/// Lift a CAPTCHA gate, a blocked sign-in and a wrong password out of the
|
||||||
|
/// generic API error.
|
||||||
///
|
///
|
||||||
/// Only a gate that can be solved in an app becomes
|
/// Only a gate that can be solved in an app becomes
|
||||||
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
||||||
@@ -512,6 +520,9 @@ fn classify_sign_in_failure(error: ProtonError) -> Error {
|
|||||||
{
|
{
|
||||||
return Error::HumanVerification(Box::new(challenge));
|
return Error::HumanVerification(Box::new(challenge));
|
||||||
}
|
}
|
||||||
|
if api.code == ResponseCode::Blocked {
|
||||||
|
return Error::SignIn(SIGN_IN_BLOCKED.to_owned());
|
||||||
|
}
|
||||||
if api.http_status == 422 && !api.is_human_verification_required() {
|
if api.http_status == 422 && !api.is_human_verification_required() {
|
||||||
return Error::SignIn(if api.message.is_empty() {
|
return Error::SignIn(if api.message.is_empty() {
|
||||||
"Proton did not accept that address and password".to_owned()
|
"Proton did not accept that address and password".to_owned()
|
||||||
@@ -642,6 +653,27 @@ mod tests {
|
|||||||
assert!(store.resume().expect("resume").is_none());
|
assert!(store.resume().expect("resume").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_session_unlocks_with_its_passphrases() {
|
||||||
|
let unlock = stored().unlock();
|
||||||
|
assert_eq!(unlock.len(), 1);
|
||||||
|
assert_eq!(unlock.get("key-1"), Some(b"derived".as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blocked_sign_in_does_not_claim_the_app_is_outdated() {
|
||||||
|
let error = classify_sign_in_failure(ProtonError::Api(proton_sdk::error::ProtonApiError {
|
||||||
|
code: ResponseCode::Blocked,
|
||||||
|
http_status: 422,
|
||||||
|
message: "This version of the app is no longer supported".to_owned(),
|
||||||
|
details: None,
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
matches!(&error, Error::SignIn(text) if text == SIGN_IN_BLOCKED),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_session_keeps_its_password_mode() {
|
fn a_stored_session_keeps_its_password_mode() {
|
||||||
let params = stored().resume_parameters();
|
let params = stored().resume_parameters();
|
||||||
|
|||||||
@@ -85,11 +85,14 @@ impl Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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, the token lacks a scope only a password
|
||||||
/// sign-in grants. Nothing but signing in again fixes it.
|
/// sign-in grants, or the stored key passphrases no longer unlock the
|
||||||
|
/// account because its mailbox password changed. Nothing but signing in
|
||||||
|
/// again fixes it.
|
||||||
pub fn is_signed_out(&self) -> bool {
|
pub fn is_signed_out(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::SignedOut(_) => true,
|
Self::SignedOut(_) => true,
|
||||||
|
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
|
||||||
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
||||||
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
||||||
}
|
}
|
||||||
@@ -127,4 +130,10 @@ mod tests {
|
|||||||
"io: client error (SendRequest): connection closed before message completed"
|
"io: client error (SendRequest): connection closed before message completed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_passphrases_that_no_longer_unlock_mean_signed_out() {
|
||||||
|
let error = Error::Drive(proton_sdk::error::ProtonError::KeysLocked);
|
||||||
|
assert!(error.is_signed_out());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,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};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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,6 +156,24 @@ pub fn pick_track(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dialogue track to play in place of the subtitle track mpv chose, when
|
||||||
|
/// mpv chose signs and songs; see [`pstr_core::subtitles::full_instead`].
|
||||||
|
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> {
|
||||||
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
||||||
let id: i64 = mpv
|
let id: i64 = mpv
|
||||||
@@ -167,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"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -200,6 +223,7 @@ mod tests {
|
|||||||
codec: None,
|
codec: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
default: false,
|
default: false,
|
||||||
|
forced: false,
|
||||||
external: false,
|
external: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -104,6 +110,17 @@ final class MpvPlayer {
|
|||||||
/// Subtitle files handed over before their file loaded, and whether each
|
/// Subtitle files handed over before their file loaded, and whether each
|
||||||
/// is the one to turn on.
|
/// is the one to turn on.
|
||||||
@ObservationIgnored private var pendingSubtitles: [(SubtitleFileRecord, Bool)] = []
|
@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? {
|
||||||
@@ -291,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.
|
||||||
@@ -314,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,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))
|
||||||
@@ -424,9 +479,13 @@ final class MpvPlayer {
|
|||||||
let ready = pendingSubtitles
|
let ready = pendingSubtitles
|
||||||
pendingSubtitles = []
|
pendingSubtitles = []
|
||||||
attach(ready)
|
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 }
|
||||||
@@ -459,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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -433,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 }
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
# builds from the registry alone.
|
# builds from the registry alone.
|
||||||
|
|
||||||
pkgname=proton-stream
|
pkgname=proton-stream
|
||||||
pkgver=1.5.1
|
pkgver=1.6.1
|
||||||
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')
|
||||||
|
|||||||
Reference in new issue
Block a user