mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
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.
This commit is contained in:
1 parent
51454b5a6e
commit
f0fe90f0fe
6 files changed
+564
-6
No files matched your search
@@ -28,7 +28,7 @@ use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||
use pstr_core::appearance::Appearance;
|
||||
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::library::{Library, Title, TitleKind};
|
||||
@@ -1513,6 +1513,7 @@ impl Engine {
|
||||
}
|
||||
};
|
||||
let rows = build_rows(&share_id, &nodes);
|
||||
let sidecars = build_sidecars(&share_id, &nodes);
|
||||
let files = rows.len();
|
||||
|
||||
// A writer opened before the crawl may be producing the revision
|
||||
@@ -1574,7 +1575,7 @@ impl Engine {
|
||||
for (link_id, _) in &stale {
|
||||
catalog.remove_offline_file(&share_id, link_id)?;
|
||||
}
|
||||
Ok(())
|
||||
catalog.replace_sidecars(&share_id, &sidecars)
|
||||
})
|
||||
};
|
||||
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
|
||||
/// time — known from the last time it played.
|
||||
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
|
||||
|
||||
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
|
||||
use pstr_core::library::{Episode, Title};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_player::{
|
||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
|
||||
VideoOutput,
|
||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
|
||||
TrackKind, VideoOutput,
|
||||
};
|
||||
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 duration: Option<f64> = None;
|
||||
let mut watched = false;
|
||||
@@ -558,6 +567,10 @@ fn run(
|
||||
match &event {
|
||||
PlayerEvent::FileLoaded => {
|
||||
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
|
||||
// demuxed the file and made its own default selection. Put the
|
||||
// title's own choice over it before the menus are told, so they
|
||||
@@ -607,6 +620,7 @@ fn run(
|
||||
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
||||
// OpenGL context its render context was built against.
|
||||
drop(handle);
|
||||
drop(subtitles);
|
||||
drop(player);
|
||||
|
||||
engine.release(
|
||||
@@ -721,6 +735,47 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||
engine.emit(Event::Player {
|
||||
|
||||
Reference in new issue
Block a user