diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 0327714b..7fa93a2a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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 { diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 1a69441e..eefbddf1 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -2471,41 +2471,62 @@ class _PlexVideoControlsState extends State with WindowListen Future _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); }