@@ -476,12 +476,17 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
// SurfaceFlinger-layer-backed overlay that eglPresentationTimeANDROID can
|
||||
// vsync-pin. Z-order: default video SurfaceView < this MediaOverlay-flagged
|
||||
// SurfaceView < Flutter TextureView in the window.
|
||||
//
|
||||
// Inserted at child index 0 so the SurfaceView's transparent punch runs BEFORE
|
||||
// SubtitleView's built-in CanvasSubtitleOutput child renders non-ASS cues.
|
||||
// Appending would punch away already-drawn SRT/VTT text.
|
||||
var assSubtitleSurfaceView: AssSubtitleSurfaceView? = null
|
||||
subtitleView?.let { sv ->
|
||||
val assView = AssSubtitleSurfaceView(sv.context, handler)
|
||||
assSubtitleSurfaceView = assView
|
||||
sv.addView(
|
||||
assView,
|
||||
0,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
@@ -1408,22 +1413,69 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
|
||||
fun addSubtitleTrack(uri: String, title: String?, language: String?, mimeType: String?, select: Boolean) {
|
||||
val index = externalSubtitles.size
|
||||
val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri))
|
||||
.setId("external_$index")
|
||||
.setLabel(title ?: "External")
|
||||
.setLanguage(language)
|
||||
.setMimeType(mimeType ?: detectSubtitleMimeType(uri))
|
||||
.build()
|
||||
val existingIndex = externalSubtitleUris.indexOf(uri)
|
||||
val isNew = existingIndex < 0
|
||||
val index = if (isNew) externalSubtitles.size else existingIndex
|
||||
val formatId = "external_$index"
|
||||
|
||||
externalSubtitles.add(subtitleConfig)
|
||||
externalSubtitleUris.add(uri)
|
||||
if (isNew) {
|
||||
// SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
|
||||
// natural selection picks it on prepare. Avoids pinning the selector to a
|
||||
// specific TrackGroup override — if the URL 404s (e.g. stale Plex stream key),
|
||||
// ExoPlayer falls back to another available track (e.g. embedded SRT) instead
|
||||
// of leaving text disabled.
|
||||
val selectionFlags = if (select) C.SELECTION_FLAG_DEFAULT else 0
|
||||
val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri))
|
||||
.setId(formatId)
|
||||
.setLabel(title ?: "External")
|
||||
.setLanguage(language)
|
||||
.setMimeType(mimeType ?: detectSubtitleMimeType(uri))
|
||||
.setSelectionFlags(selectionFlags)
|
||||
.build()
|
||||
externalSubtitles.add(subtitleConfig)
|
||||
externalSubtitleUris.add(uri)
|
||||
}
|
||||
|
||||
// Note: ExoPlayer won't see these until the media is reloaded.
|
||||
// On Android, external subs are normally passed at open() time.
|
||||
// This path is only reached if the Flutter layer calls addSubtitleTrack
|
||||
// while ExoPlayer (not MPV fallback) is active.
|
||||
emitTrackList()
|
||||
// Media3 only picks up MediaItem.SubtitleConfiguration at prepare() time
|
||||
// (tracking issue androidx/media #1649). When the caller wants this subtitle
|
||||
// activated immediately (e.g. after OpenSubtitles download), rebuild the
|
||||
// MediaItem and re-prepare with the position preserved.
|
||||
val player = exoPlayer
|
||||
val mediaUri = currentMediaUri
|
||||
if (select && player != null && mediaUri != null && !currentMediaIsLive) {
|
||||
if (isNew) {
|
||||
val savedPosition = player.currentPosition
|
||||
val savedPlayWhenReady = player.playWhenReady
|
||||
|
||||
// Clear any stale text-type override (pointing at a pre-reload TrackGroup)
|
||||
// and re-enable the text type — mirrors the reset done in open(). Without
|
||||
// this, a previously-selected sub's override would either block the new
|
||||
// DEFAULT-flagged sub from winning or, if the new sub fails to load, keep
|
||||
// the text renderer stuck with no selection.
|
||||
trackSelector?.let { selector ->
|
||||
selector.parameters = selector.buildUponParameters()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.build()
|
||||
}
|
||||
selectedSubtitleTrackId = null
|
||||
|
||||
val mediaItem = buildMediaItem(mediaUri)
|
||||
player.setMediaItem(mediaItem, savedPosition)
|
||||
player.prepare()
|
||||
player.playWhenReady = savedPlayWhenReady
|
||||
} else {
|
||||
// Already attached — select the existing track via override.
|
||||
val trackId = subtitleTrackGroupMap.entries
|
||||
.firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
|
||||
?.key
|
||||
if (trackId != null) {
|
||||
selectSubtitleTrack(trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew) emitTrackList()
|
||||
}
|
||||
|
||||
private fun detectSubtitleMimeType(uri: String): String {
|
||||
|
||||
@@ -2471,41 +2471,62 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
|
||||
Future<void> _onSubtitleDownloaded() async {
|
||||
if (!mounted) return;
|
||||
// Wait for the server to finish downloading the subtitle file
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final client = _getClientForMetadata();
|
||||
final data = await client.getVideoPlaybackData(widget.metadata.ratingKey);
|
||||
if (!mounted || data.mediaInfo == null) return;
|
||||
|
||||
final token = client.config.token;
|
||||
if (token == null) return;
|
||||
|
||||
// Find external subtitle tracks from the refreshed metadata
|
||||
// Plex's OpenSubtitles download is asynchronous: the PUT returns immediately
|
||||
// but the new stream entry shows up in metadata seconds later. Poll until it
|
||||
// appears. Up to 15s matches what Plex-web tolerates before giving up.
|
||||
// Snapshot what's already attached so we can identify the new download.
|
||||
final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet();
|
||||
|
||||
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
|
||||
if (!plexTrack.isExternal) continue;
|
||||
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
|
||||
if (url == null) continue;
|
||||
// Skip tracks already loaded in the player
|
||||
if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue;
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 15));
|
||||
PlexSubtitleTrack? newTrack;
|
||||
String? newUrl;
|
||||
PlexMediaInfo? latestInfo;
|
||||
|
||||
await widget.player.addSubtitleTrack(
|
||||
uri: url,
|
||||
title: plexTrack.displayTitle ?? plexTrack.language ?? 'Downloaded',
|
||||
language: plexTrack.languageCode,
|
||||
select: true,
|
||||
);
|
||||
while (mounted && DateTime.now().isBefore(deadline)) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
// Save the selection on the server so it persists across sessions
|
||||
final partId = data.mediaInfo!.partId;
|
||||
if (partId != null) {
|
||||
await client.selectStreams(partId, subtitleStreamID: plexTrack.id);
|
||||
try {
|
||||
final data = await client.getVideoPlaybackData(widget.metadata.ratingKey);
|
||||
if (!mounted) return;
|
||||
if (data.mediaInfo == null) continue;
|
||||
latestInfo = data.mediaInfo;
|
||||
|
||||
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
|
||||
if (!plexTrack.isExternal) continue;
|
||||
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
|
||||
if (url == null) continue;
|
||||
if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue;
|
||||
|
||||
newTrack = plexTrack;
|
||||
newUrl = url;
|
||||
break;
|
||||
}
|
||||
if (newTrack != null) break;
|
||||
} catch (e) {
|
||||
appLogger.w('Subtitle download poll iteration failed', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || newTrack == null || newUrl == null) return;
|
||||
|
||||
await widget.player.addSubtitleTrack(
|
||||
uri: newUrl,
|
||||
title: newTrack.displayTitle ?? newTrack.language ?? 'Downloaded',
|
||||
language: newTrack.languageCode,
|
||||
select: true,
|
||||
);
|
||||
|
||||
final partId = latestInfo?.partId;
|
||||
if (partId != null) {
|
||||
await client.selectStreams(partId, subtitleStreamID: newTrack.id);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to refresh subtitles after download', error: e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user