From 943f7dab08757d0d5b67f4ba71d20f4f12cb0042 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:29:35 +0100 Subject: [PATCH] feat: exoplayer --- android/app/build.gradle.kts | 19 + android/app/src/main/AndroidManifest.xml | 3 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 1083 +++++++++++++++++ .../plezy/exoplayer/ExoPlayerPlugin.kt | 506 ++++++++ .../flutter_application_1/MainActivity.kt | 15 + lib/i18n/de.i18n.json | 8 +- lib/i18n/en.i18n.json | 8 +- lib/i18n/fr.i18n.json | 8 +- lib/i18n/it.i18n.json | 8 +- lib/i18n/ko.i18n.json | 8 +- lib/i18n/nl.i18n.json | 8 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 16 +- lib/i18n/strings_en.g.dart | 28 +- lib/i18n/strings_fr.g.dart | 16 +- lib/i18n/strings_it.g.dart | 16 +- lib/i18n/strings_ko.g.dart | 16 +- lib/i18n/strings_nl.g.dart | 16 +- lib/i18n/strings_sv.g.dart | 16 +- lib/i18n/strings_zh.g.dart | 16 +- lib/i18n/sv.i18n.json | 8 +- lib/i18n/zh.i18n.json | 8 +- lib/mpv/player/player.dart | 21 +- lib/mpv/player/player_android.dart | 637 ++++++++++ lib/mpv/player/player_native.dart | 4 + lib/mpv/player/player_streams.dart | 5 + lib/screens/settings/settings_screen.dart | 77 +- lib/screens/video_player_screen.dart | 23 +- lib/services/settings_service.dart | 12 + 29 files changed, 2572 insertions(+), 41 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt create mode 100644 lib/mpv/player/player_android.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 52e31986..b436070b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -57,6 +57,13 @@ android { // If key.properties doesn't exist, it will use debug signing for CI builds } } + + packaging { + jniLibs { + // Resolve conflict between libass-android and libmpv native libraries + pickFirsts.add("lib/*/libc++_shared.so") + } + } } flutter { @@ -65,4 +72,16 @@ flutter { dependencies { implementation("dev.jdtech.mpv:libmpv:0.5.1") + + // Media3 ExoPlayer for Android + implementation("androidx.media3:media3-exoplayer:1.5.1") + implementation("androidx.media3:media3-ui:1.5.1") + implementation("androidx.media3:media3-common:1.5.1") + implementation("androidx.media3:media3-session:1.5.1") + + // FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.) + implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1") + + // libass-android for ASS/SSA subtitle rendering + implementation("io.github.peerless2012:ass-media:0.4.0-beta01") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b0b29085..3d1006ab 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,8 @@ android:label="Plezy" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:appCategory="video"> + android:appCategory="video" + android:usesCleartextTraffic="true"> ?) + + /** + * Called when ExoPlayer encounters a format it cannot play. + * The plugin should handle fallback to MPV. + * @return true if fallback was handled, false to emit error event to Flutter + */ + fun onFormatUnsupported( + uri: String, + headers: Map?, + positionMs: Long, + errorMessage: String + ): Boolean = false +} + +@OptIn(UnstableApi::class) +class ExoPlayerCore(private val activity: Activity) : Player.Listener { + + companion object { + private const val TAG = "ExoPlayerCore" + private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes + } + + private var surfaceView: SurfaceView? = null + private var surfaceContainer: FrameLayout? = null + private var subtitleView: SubtitleView? = null + private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null + private var exoPlayer: ExoPlayer? = null + private var trackSelector: DefaultTrackSelector? = null + var delegate: ExoPlayerDelegate? = null + var isInitialized: Boolean = false + private set + + // Frame rate matching + private var currentVideoFps: Float = 0f + private var displayListener: DisplayManager.DisplayListener? = null + private val handler = Handler(Looper.getMainLooper()) + + // Audio focus + private var audioManager: AudioManager? = null + private var audioFocusRequest: AudioFocusRequest? = null + private var hasAudioFocus: Boolean = false + private var wasPlayingBeforeFocusLoss: Boolean = false + + // Track state for event emission + private var lastPosition: Long = 0 + private var lastDuration: Long = 0 + private var lastBufferedPosition: Long = 0 + private var positionUpdateRunnable: Runnable? = null + + // External subtitles added dynamically + private val externalSubtitles = mutableListOf() + private var currentMediaUri: String? = null + private var currentHeaders: Map? = null + + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + Log.d(TAG, "Audio focus gained") + hasAudioFocus = true + if (wasPlayingBeforeFocusLoss && isInitialized) { + exoPlayer?.play() + wasPlayingBeforeFocusLoss = false + } + } + AudioManager.AUDIOFOCUS_LOSS -> { + Log.d(TAG, "Audio focus lost permanently") + hasAudioFocus = false + if (isInitialized) { + wasPlayingBeforeFocusLoss = exoPlayer?.isPlaying == true + exoPlayer?.pause() + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + Log.d(TAG, "Audio focus lost transiently") + hasAudioFocus = false + if (isInitialized) { + wasPlayingBeforeFocusLoss = exoPlayer?.isPlaying == true + exoPlayer?.pause() + } + } + } + } + + private fun ensureFlutterOverlayOnTop() { + val contentView = activity.findViewById(android.R.id.content) + contentView.post { + var flutterContainer: ViewGroup? = null + + for (i in 0 until contentView.childCount) { + val child = contentView.getChildAt(i) + if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) { + flutterContainer = child + break + } + } + + if (flutterContainer == null) { + for (i in contentView.childCount - 1 downTo 0) { + val child = contentView.getChildAt(i) + if (child is ViewGroup && child != surfaceContainer && child.childCount > 0) { + flutterContainer = child + break + } + } + } + + flutterContainer?.let { container -> + contentView.bringChildToFront(container) + for (j in 0 until container.childCount) { + val flutterChild = container.getChildAt(j) + if (flutterChild is SurfaceView) { + // Don't use setZOrderOnTop - let Flutter render in normal view order + // This allows SubtitleView (added via addContentView) to render above + flutterChild.setZOrderOnTop(false) + flutterChild.setZOrderMediaOverlay(true) + flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT) + break + } else if (flutterChild is TextureView) { + flutterChild.isOpaque = false + break + } + } + } + + } + } + + private fun configureSubtitleOverlaySurface() { + subtitleView?.post { + val count = subtitleView?.childCount ?: 0 + for (i in 0 until count) { + val child = subtitleView?.getChildAt(i) + if (child is SurfaceView) { + child.setZOrderOnTop(false) + child.setZOrderMediaOverlay(true) + child.holder.setFormat(PixelFormat.TRANSLUCENT) + } else if (child is TextureView) { + child.isOpaque = false + } + } + } + } + + fun initialize(): Boolean { + if (isInitialized) { + Log.d(TAG, "Already initialized") + return true + } + + try { + audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + // Create FrameLayout container for video (enables centering for aspect ratio) + surfaceContainer = FrameLayout(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(Color.BLACK) + } + + // Create SurfaceView for video rendering + surfaceView = SurfaceView(activity).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ).apply { + gravity = Gravity.CENTER + } + holder.addCallback(surfaceCallback) + setZOrderOnTop(false) + setZOrderMediaOverlay(false) + } + + surfaceContainer!!.addView(surfaceView) + + // Create SubtitleView - will be added to surfaceContainer above video + // With OVERLAY_CANVAS mode, libass-android adds AssSubtitleView as a child + // which renders ASS subtitles with full styling + subtitleView = SubtitleView(activity).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + Log.d(TAG, "SubtitleView created") + + val contentView = activity.findViewById(android.R.id.content) + contentView.addView(surfaceContainer, 0) + + // Find FlutterView and configure z-order + // Video SurfaceView is at the bottom (setZOrderOnTop=false, setZOrderMediaOverlay=false) + // Flutter SurfaceView uses setZOrderMediaOverlay to render above video + // SubtitleView will be added to surfaceContainer so Flutter stays on top + for (i in 0 until contentView.childCount) { + val child = contentView.getChildAt(i) + if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) { + contentView.bringChildToFront(child) + for (j in 0 until child.childCount) { + val flutterChild = child.getChildAt(j) + if (flutterChild is SurfaceView) { + // Use setZOrderMediaOverlay instead of setZOrderOnTop + // This puts Flutter above video but below normal views + flutterChild.setZOrderOnTop(false) + flutterChild.setZOrderMediaOverlay(true) + flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT) + break + } + } + break + } + } + + // Add SubtitleView just below FlutterView so it stays above video but below Flutter UI + var flutterViewIndex: Int? = null + for (i in 0 until contentView.childCount) { + val child = contentView.getChildAt(i) + if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) { + flutterViewIndex = i + break + } + } + if (flutterViewIndex != null) { + contentView.addView(subtitleView, flutterViewIndex) + Log.d(TAG, "SubtitleView added below FlutterView at index $flutterViewIndex") + } else { + contentView.addView(subtitleView) + Log.d(TAG, "SubtitleView added to contentView (FlutterView not found)") + } + ensureFlutterOverlayOnTop() + overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + ensureFlutterOverlayOnTop() + } + contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener) + + Log.d(TAG, "SurfaceView added to content view") + + // Create track selector with text tracks enabled + trackSelector = DefaultTrackSelector(activity).apply { + setParameters( + buildUponParameters() + .setTunnelingEnabled(true) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .setPreferredTextLanguage("en") + ) + } + + // Create ExoPlayer with FFmpeg audio decoder fallback + val audioAttributes = androidx.media3.common.AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build() + + // Use DefaultRenderersFactory with FFmpeg fallback for unsupported audio codecs + val renderersFactory = DefaultRenderersFactory(activity).apply { + // Enable decoder fallback - if MediaCodec fails, try FFmpeg + setEnableDecoderFallback(true) + // Use FFmpeg decoders as fallback (not preferred over native) + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + } + + // Create factories for buildWithAssSupport (like AndroidTV-FireTV) + val dataSourceFactory = DefaultDataSource.Factory(activity) + val extractorsFactory = DefaultExtractorsFactory() + + // Use buildWithAssSupport with OVERLAY_CANVAS mode for proper libass rendering + // This renders ASS subtitles with full styling via AssSubtitleView + Log.d(TAG, "SubtitleView childCount before buildWithAssSupport: ${subtitleView?.childCount}") + exoPlayer = ExoPlayer.Builder(activity) + .setTrackSelector(trackSelector!!) + .setAudioAttributes(audioAttributes, false) // We handle audio focus manually + .buildWithAssSupport( + context = activity, + renderType = AssRenderType.OVERLAY_CANVAS, // Use OVERLAY mode for libass styling + subtitleView = subtitleView, + dataSourceFactory = dataSourceFactory, + extractorsFactory = extractorsFactory, + renderersFactory = renderersFactory + ) + .also { player -> + player.addListener(this) + surfaceView?.let { player.setVideoSurfaceView(it) } + } + Log.d(TAG, "SubtitleView childCount after buildWithAssSupport: ${subtitleView?.childCount}") + configureSubtitleOverlaySurface() + + // Debug: Log SubtitleView child hierarchy + subtitleView?.post { + Log.d(TAG, "SubtitleView post-layout: width=${subtitleView?.width}, height=${subtitleView?.height}, childCount=${subtitleView?.childCount}") + for (i in 0 until (subtitleView?.childCount ?: 0)) { + val child = subtitleView?.getChildAt(i) + Log.d(TAG, " Child $i: ${child?.javaClass?.simpleName}, w=${child?.width}, h=${child?.height}, visibility=${child?.visibility}") + } + } + + // Start position update loop + startPositionUpdates() + + isInitialized = true + Log.d(TAG, "Initialized successfully") + return true + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + return false + } + } + + private val surfaceCallback = object : android.view.SurfaceHolder.Callback { + override fun surfaceCreated(holder: android.view.SurfaceHolder) { + Log.d(TAG, "Surface created") + ensureFlutterOverlayOnTop() + } + + override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, width: Int, height: Int) { + Log.d(TAG, "Surface changed: ${width}x${height}") + } + + override fun surfaceDestroyed(holder: android.view.SurfaceHolder) { + Log.d(TAG, "Surface destroyed") + } + } + + private fun startPositionUpdates() { + positionUpdateRunnable = object : Runnable { + override fun run() { + if (isInitialized && exoPlayer != null) { + val player = exoPlayer!! + val currentPosition = player.currentPosition + val duration = player.duration + val bufferedPosition = player.bufferedPosition + + // Emit position changes (every 250ms update) + if (currentPosition != lastPosition) { + lastPosition = currentPosition + delegate?.onPropertyChange("time-pos", currentPosition / 1000.0) + } + + // Emit duration changes + if (duration != lastDuration && duration != C.TIME_UNSET) { + lastDuration = duration + delegate?.onPropertyChange("duration", duration / 1000.0) + } + + // Emit buffer changes + if (bufferedPosition != lastBufferedPosition && bufferedPosition != C.TIME_UNSET) { + lastBufferedPosition = bufferedPosition + delegate?.onPropertyChange("demuxer-cache-time", bufferedPosition / 1000.0) + } + + handler.postDelayed(this, 250) + } + } + } + handler.post(positionUpdateRunnable!!) + } + + private fun stopPositionUpdates() { + positionUpdateRunnable?.let { handler.removeCallbacks(it) } + positionUpdateRunnable = null + } + + // Player.Listener + + override fun onCues(cueGroup: CueGroup) { + // With OVERLAY_CANVAS mode, ASS subtitles are rendered directly by AssSubtitleView + // This callback is for non-ASS subtitles (SRT, VTT, etc.) + if (cueGroup.cues.isNotEmpty()) { + Log.d(TAG, "onCues: received ${cueGroup.cues.size} cues (non-ASS)") + } + subtitleView?.setCues(cueGroup.cues) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(TAG, "onIsPlayingChanged: $isPlaying") + delegate?.onPropertyChange("pause", !isPlaying) + } + + override fun onPlaybackStateChanged(state: Int) { + val stateStr = when (state) { + Player.STATE_IDLE -> "idle" + Player.STATE_BUFFERING -> "buffering" + Player.STATE_READY -> "ready" + Player.STATE_ENDED -> "ended" + else -> "unknown" + } + Log.d(TAG, "onPlaybackStateChanged: $stateStr") + + when (state) { + Player.STATE_BUFFERING -> { + delegate?.onPropertyChange("paused-for-cache", true) + } + Player.STATE_READY -> { + delegate?.onPropertyChange("paused-for-cache", false) + delegate?.onEvent("playback-restart", null) + emitTrackList() + } + Player.STATE_ENDED -> { + delegate?.onPropertyChange("eof-reached", true) + delegate?.onEvent("end-file", mapOf("reason" to "eof")) + } + } + } + + override fun onTracksChanged(tracks: Tracks) { + Log.d(TAG, "onTracksChanged") + emitTrackList() + } + + override fun onPlayerError(error: PlaybackException) { + Log.e(TAG, "Player error: ${error.message}", error) + + // Detect format/codec unsupported errors + val isFormatUnsupported = when { + error.errorCode == PlaybackException.ERROR_CODE_DECODING_FAILED -> true + error.errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> true + error.errorCode == PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> true + error.cause?.message?.contains("EXCEEDS_CAPABILITIES") == true -> true + error.cause?.message?.contains("MediaCodec") == true -> true + else -> false + } + + if (isFormatUnsupported && currentMediaUri != null) { + Log.w(TAG, "Format unsupported - attempting fallback to MPV") + val handled = delegate?.onFormatUnsupported( + uri = currentMediaUri!!, + headers = currentHeaders, + positionMs = lastPosition, + errorMessage = error.message ?: "Unknown format error" + ) ?: false + + if (handled) { + // Fallback was handled by the plugin, don't emit error + return + } + } + + // Fallback not handled or not a format error - emit error event + delegate?.onEvent("end-file", mapOf( + "reason" to "error", + "message" to (error.message ?: "Unknown error") + )) + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason") + delegate?.onEvent("file-loaded", null) + delegate?.onPropertyChange("eof-reached", false) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + Log.d(TAG, "Video size changed: ${videoSize.width}x${videoSize.height}, ratio: ${videoSize.pixelWidthHeightRatio}") + updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio) + } + + private fun updateSurfaceViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) { + if (videoWidth == 0 || videoHeight == 0) return + + val surface = surfaceView ?: return + val subtitle = subtitleView + val contentView = activity.findViewById(android.R.id.content) + + val containerWidth = contentView.width + val containerHeight = contentView.height + if (containerWidth == 0 || containerHeight == 0) return + + // Calculate video aspect ratio (accounting for non-square pixels) + val videoAspect = (videoWidth * pixelRatio) / videoHeight + val containerAspect = containerWidth.toFloat() / containerHeight + + val (newWidth, newHeight) = if (videoAspect > containerAspect) { + // Video is wider - fit to width, letterbox top/bottom + containerWidth to (containerWidth / videoAspect).toInt() + } else { + // Video is taller - fit to height, pillarbox left/right + (containerHeight * videoAspect).toInt() to containerHeight + } + + activity.runOnUiThread { + surface.layoutParams = FrameLayout.LayoutParams(newWidth, newHeight).apply { + gravity = Gravity.CENTER + } + surface.requestLayout() + subtitle?.let { sv -> + sv.layoutParams = FrameLayout.LayoutParams(newWidth, newHeight).apply { + gravity = Gravity.CENTER + } + sv.requestLayout() + } + } + } + + private fun emitTrackList() { + val player = exoPlayer ?: return + val tracks = player.currentTracks + + val trackList = mutableListOf>() + + // Group tracks by type and use group index as track ID (matching select functions) + val audioGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO } + val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } + val videoGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO } + + var selectedAudioId: String? = null + var selectedSubId: String? = null + + // Process audio tracks + audioGroups.forEachIndexed { groupIndex, group -> + val trackGroup = group.mediaTrackGroup + // Use first format in group as the representative track + val format = trackGroup.getFormat(0) + val trackId = "${C.TRACK_TYPE_AUDIO}_$groupIndex" + val isSelected = group.isSelected + + val track = mutableMapOf( + "type" to "audio", + "id" to trackId, + "title" to format.label, + "lang" to format.language, + "codec" to format.codecs, + "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "selected" to isSelected, + "demux-channel-count" to format.channelCount, + "demux-samplerate" to format.sampleRate + ) + trackList.add(track) + + if (isSelected) { + selectedAudioId = trackId + } + } + + // Process subtitle tracks + Log.d(TAG, "emitTrackList: found ${textGroups.size} subtitle track groups") + textGroups.forEachIndexed { groupIndex, group -> + val trackGroup = group.mediaTrackGroup + val format = trackGroup.getFormat(0) + val trackId = "${C.TRACK_TYPE_TEXT}_$groupIndex" + val isSelected = group.isSelected + + Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected") + + val track = mutableMapOf( + "type" to "sub", + "id" to trackId, + "title" to format.label, + "lang" to format.language, + "codec" to format.codecs, + "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "selected" to isSelected, + "external" to false + ) + trackList.add(track) + + if (isSelected) { + selectedSubId = trackId + } + } + + // Process video tracks (for completeness, typically only one) + videoGroups.forEachIndexed { groupIndex, group -> + val trackGroup = group.mediaTrackGroup + val format = trackGroup.getFormat(0) + val trackId = "${C.TRACK_TYPE_VIDEO}_$groupIndex" + + val track = mutableMapOf( + "type" to "video", + "id" to trackId, + "title" to format.label, + "lang" to format.language, + "codec" to format.codecs, + "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0), + "selected" to group.isSelected + ) + trackList.add(track) + } + + // Emit selected track IDs + selectedAudioId?.let { delegate?.onPropertyChange("aid", it) } + if (selectedSubId != null) { + delegate?.onPropertyChange("sid", selectedSubId) + } else if (textGroups.isNotEmpty()) { + // No subtitle selected + delegate?.onPropertyChange("sid", "no") + } + + // Add external subtitles to track list + externalSubtitles.forEachIndexed { index, subtitle -> + val extTrackId = "ext_sub_$index" + trackList.add(mapOf( + "type" to "sub", + "id" to extTrackId, + "title" to (subtitle.label ?: "External"), + "lang" to subtitle.language, + "codec" to subtitle.mimeType, + "default" to false, + "external" to true, + "external-filename" to subtitle.uri.toString() + )) + } + + delegate?.onPropertyChange("track-list", trackList) + } + + // Public API + + fun open(uri: String, headers: Map?, startPositionMs: Long, autoPlay: Boolean) { + if (!isInitialized) return + + currentMediaUri = uri + currentHeaders = headers + externalSubtitles.clear() + + val mediaItemBuilder = MediaItem.Builder() + .setUri(uri) + + // Add headers if provided + if (!headers.isNullOrEmpty()) { + val dataSourceFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(headers) + // Note: For proper header support, we'd need to configure this at player level + // For now, headers are handled via URL parameters if needed + } + + val mediaItem = mediaItemBuilder.build() + + exoPlayer?.apply { + setMediaItem(mediaItem, startPositionMs) + prepare() + playWhenReady = autoPlay + } + + Log.d(TAG, "Opened: $uri, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay") + } + + fun play() { + exoPlayer?.play() + } + + fun pause() { + exoPlayer?.pause() + } + + fun stop() { + exoPlayer?.stop() + setVisible(false) + } + + fun seekTo(positionMs: Long) { + exoPlayer?.seekTo(positionMs) + } + + fun setVolume(volume: Float) { + exoPlayer?.volume = volume.coerceIn(0f, 1f) + delegate?.onPropertyChange("volume", (volume * 100).toDouble()) + } + + fun setPlaybackSpeed(speed: Float) { + exoPlayer?.setPlaybackSpeed(speed.coerceIn(0.25f, 4f)) + delegate?.onPropertyChange("speed", speed.toDouble()) + } + + fun selectAudioTrack(trackId: String) { + val player = exoPlayer ?: return + val selector = trackSelector ?: return + + // Parse track ID (format: "type_index") + val parts = trackId.split("_") + if (parts.size < 2) return + + val trackIndex = parts[1].toIntOrNull() ?: return + + val audioGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO } + if (trackIndex >= 0 && trackIndex < audioGroups.size) { + val group = audioGroups[trackIndex] + selector.parameters = selector.buildUponParameters() + .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0)) + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .build() + + delegate?.onPropertyChange("aid", trackId) + } + } + + fun selectSubtitleTrack(trackId: String?) { + val player = exoPlayer ?: return + val selector = trackSelector ?: return + + if (trackId == null || trackId == "no") { + // Disable subtitles + selector.parameters = selector.buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + .build() + delegate?.onPropertyChange("sid", "no") + return + } + + // Check if external subtitle + if (trackId.startsWith("ext_sub_")) { + val index = trackId.removePrefix("ext_sub_").toIntOrNull() ?: return + if (index >= 0 && index < externalSubtitles.size) { + // Reload media with selected external subtitle + reloadWithExternalSubtitle(index) + return + } + } + + // Parse track ID for embedded subtitles + val parts = trackId.split("_") + if (parts.size < 2) return + + val trackIndex = parts[1].toIntOrNull() ?: return + + val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } + if (trackIndex >= 0 && trackIndex < textGroups.size) { + val group = textGroups[trackIndex] + selector.parameters = selector.buildUponParameters() + .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0)) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .build() + + delegate?.onPropertyChange("sid", trackId) + } + } + + private fun reloadWithExternalSubtitle(subtitleIndex: Int) { + val uri = currentMediaUri ?: return + val player = exoPlayer ?: return + + val currentPosition = player.currentPosition + val wasPlaying = player.isPlaying + + val mediaItemBuilder = MediaItem.Builder() + .setUri(uri) + + // Add the selected external subtitle + if (subtitleIndex >= 0 && subtitleIndex < externalSubtitles.size) { + val subtitle = externalSubtitles[subtitleIndex] + mediaItemBuilder.setSubtitleConfigurations(listOf(subtitle)) + } + + val mediaItem = mediaItemBuilder.build() + + player.setMediaItem(mediaItem, currentPosition) + player.prepare() + player.playWhenReady = wasPlaying + + delegate?.onPropertyChange("sid", "ext_sub_$subtitleIndex") + } + + fun addSubtitleTrack(uri: String, title: String?, language: String?, mimeType: String?, select: Boolean) { + val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri)) + .setLabel(title ?: "External") + .setLanguage(language) + .setMimeType(mimeType ?: detectSubtitleMimeType(uri)) + .setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0) + .build() + + externalSubtitles.add(subtitleConfig) + + // Emit updated track list + emitTrackList() + + if (select) { + selectSubtitleTrack("ext_sub_${externalSubtitles.size - 1}") + } + } + + private fun detectSubtitleMimeType(uri: String): String { + val lowerUri = uri.lowercase() + return when { + lowerUri.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP + lowerUri.endsWith(".ass") || lowerUri.endsWith(".ssa") -> MimeTypes.TEXT_SSA + lowerUri.endsWith(".vtt") -> MimeTypes.TEXT_VTT + lowerUri.endsWith(".ttml") -> MimeTypes.APPLICATION_TTML + else -> MimeTypes.APPLICATION_SUBRIP + } + } + + fun setVisible(visible: Boolean) { + activity.runOnUiThread { + surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE + // subtitleView is inside surfaceContainer, inherits visibility + Log.d(TAG, "setVisible($visible)") + } + } + + // Audio Focus + + fun requestAudioFocus(): Boolean { + val am = audioManager ?: return false + + Log.d(TAG, "Requesting audio focus") + + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + ) + .setOnAudioFocusChangeListener(audioFocusChangeListener, handler) + .setWillPauseWhenDucked(true) + .build() + + audioFocusRequest = focusRequest + am.requestAudioFocus(focusRequest) + } else { + @Suppress("DEPRECATION") + am.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ) + } + + hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) + Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus") + return hasAudioFocus + } + + fun abandonAudioFocus() { + val am = audioManager ?: return + + Log.d(TAG, "Abandoning audio focus") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { am.abandonAudioFocusRequest(it) } + audioFocusRequest = null + } else { + @Suppress("DEPRECATION") + am.abandonAudioFocus(audioFocusChangeListener) + } + + hasAudioFocus = false + wasPlayingBeforeFocusLoss = false + } + + // Frame Rate Matching + + private fun getDisplayManager(): DisplayManager { + return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + } + + fun setVideoFrameRate(fps: Float, videoDurationMs: Long) { + currentVideoFps = fps + if (fps <= 0f) { + Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping") + return + } + + val surface = surfaceView?.holder?.surface + if (surface == null) { + Log.d(TAG, "setVideoFrameRate: Surface not available") + return + } + + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") + + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps) + } + } + + fun clearVideoFrameRate() { + Log.d(TAG, "clearVideoFrameRate") + currentVideoFps = 0f + displayListener?.let { + getDisplayManager().unregisterDisplayListener(it) + displayListener = null + } + } + + private fun registerDisplayListener() { + displayListener?.let { + getDisplayManager().unregisterDisplayListener(it) + } + + displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + override fun onDisplayRemoved(displayId: Int) = Unit + override fun onDisplayChanged(displayId: Int) { + handler.postDelayed({ + if (exoPlayer?.isPlaying == false && wasPlayingBeforeFocusLoss) { + Log.d(TAG, "Display changed, resuming playback") + exoPlayer?.play() + } + }, 2000L) + getDisplayManager().unregisterDisplayListener(this) + displayListener = null + } + } + getDisplayManager().registerDisplayListener(displayListener, handler) + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun setFrameRateR(fps: Float, surface: Surface) { + Log.d(TAG, "setFrameRateR: Setting frame rate to $fps") + surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE) + registerDisplayListener() + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) { + Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") + + if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) { + Log.d(TAG, "Short video, using seamless-only switching") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + return + } + + var seamless = false + activity.display?.mode?.alternativeRefreshRates?.let { refreshRates -> + for (rate in refreshRates) { + if (fps.toString().startsWith(rate.toString()) || + rate.toString().startsWith(fps.toString()) || + rate % fps == 0f) { + seamless = true + break + } + } + } + + if (seamless) { + Log.d(TAG, "Seamless switch available, using CHANGE_FRAME_RATE_ALWAYS") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS + ) + registerDisplayListener() + } else { + val userPreference = getDisplayManager().matchContentFrameRateUserPreference + if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { + Log.d(TAG, "User preference allows non-seamless switch") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS + ) + registerDisplayListener() + } else { + Log.d(TAG, "Non-seamless switch not allowed, using seamless-only") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun setFrameRateM(fps: Float) { + Log.d(TAG, "setFrameRateM: fps=$fps") + val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager + @Suppress("DEPRECATION") + val display = wm.defaultDisplay ?: return + + display.supportedModes?.let { supportedModes -> + val currentMode = display.mode + var modeToUse = currentMode + + for (mode in supportedModes) { + if (mode.physicalHeight != currentMode.physicalHeight || + mode.physicalWidth != currentMode.physicalWidth) { + continue + } + + if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) == + BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { + modeToUse = mode + break + } else if (mode.refreshRate % fps == 0f) { + modeToUse = mode + break + } + } + + if (modeToUse != currentMode) { + Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") + activity.window?.attributes?.let { attrs -> + attrs.preferredDisplayModeId = modeToUse.modeId + activity.window?.attributes = attrs + } + registerDisplayListener() + } + } + } + + // Cleanup + + fun dispose() { + Log.d(TAG, "Disposing") + + stopPositionUpdates() + clearVideoFrameRate() + abandonAudioFocus() + audioManager = null + + exoPlayer?.removeListener(this) + exoPlayer?.release() + exoPlayer = null + trackSelector = null + + surfaceView?.holder?.removeCallback(surfaceCallback) + overlayLayoutListener?.let { listener -> + val contentView = activity.findViewById(android.R.id.content) + contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + + val contentView = activity.findViewById(android.R.id.content) + surfaceContainer?.let { contentView.removeView(it) } + subtitleView?.let { contentView.removeView(it) } + surfaceContainer = null + surfaceView = null + subtitleView = null + + isInitialized = false + Log.d(TAG, "Disposed") + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt new file mode 100644 index 00000000..4b6b03b5 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -0,0 +1,506 @@ +package com.edde746.plezy.exoplayer + +import android.app.Activity +import android.util.Log +import com.edde746.plezy.mpv.MpvPlayerCore +import com.edde746.plezy.mpv.MpvPlayerDelegate +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate, MpvPlayerDelegate { + + companion object { + private const val TAG = "ExoPlayerPlugin" + private const val METHOD_CHANNEL = "com.plezy/exo_player" + private const val EVENT_CHANNEL = "com.plezy/exo_player/events" + } + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + private var playerCore: ExoPlayerCore? = null + private var mpvCore: MpvPlayerCore? = null // MPV fallback player + private var usingMpvFallback: Boolean = false + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + + // FlutterPlugin + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(this) + + Log.d(TAG, "Attached to engine") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + Log.d(TAG, "Detached from engine") + } + + // ActivityAware + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Attached to activity") + } + + override fun onDetachedFromActivity() { + playerCore?.dispose() + playerCore = null + mpvCore?.dispose() + mpvCore = null + usingMpvFallback = false + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity") + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Reattached to activity for config changes") + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity for config changes") + } + + // EventChannel.StreamHandler + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + Log.d(TAG, "Event stream connected") + } + + override fun onCancel(arguments: Any?) { + eventSink = null + Log.d(TAG, "Event stream disconnected") + } + + // MethodChannel.MethodCallHandler + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialize" -> handleInitialize(result) + "dispose" -> handleDispose(result) + "open" -> handleOpen(call, result) + "play" -> handlePlay(result) + "pause" -> handlePause(result) + "stop" -> handleStop(result) + "seek" -> handleSeek(call, result) + "setVolume" -> handleSetVolume(call, result) + "setRate" -> handleSetRate(call, result) + "selectAudioTrack" -> handleSelectAudioTrack(call, result) + "selectSubtitleTrack" -> handleSelectSubtitleTrack(call, result) + "addSubtitleTrack" -> handleAddSubtitleTrack(call, result) + "setVisible" -> handleSetVisible(call, result) + "setVideoFrameRate" -> handleSetVideoFrameRate(call, result) + "clearVideoFrameRate" -> handleClearVideoFrameRate(result) + "requestAudioFocus" -> handleRequestAudioFocus(result) + "abandonAudioFocus" -> handleAbandonAudioFocus(result) + "isInitialized" -> result.success( + if (usingMpvFallback) mpvCore?.isInitialized ?: false + else playerCore?.isInitialized ?: false + ) + else -> result.notImplemented() + } + } + + private fun handleInitialize(result: MethodChannel.Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + if (playerCore?.isInitialized == true) { + Log.d(TAG, "Already initialized") + result.success(true) + return + } + + currentActivity.runOnUiThread { + try { + playerCore = ExoPlayerCore(currentActivity).apply { + delegate = this@ExoPlayerPlugin + } + val success = playerCore?.initialize() ?: false + + // Start hidden + playerCore?.setVisible(false) + + Log.d(TAG, "Initialized: $success") + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + result.error("INIT_FAILED", e.message, null) + } + } + } + + private fun handleDispose(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.dispose() + mpvCore = null + } else { + playerCore?.dispose() + playerCore = null + } + usingMpvFallback = false + Log.d(TAG, "Disposed") + result.success(null) + } ?: result.success(null) + } + + private fun handleOpen(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri") + val headers = call.argument>("headers") + val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L + val autoPlay = call.argument("autoPlay") ?: true + + if (uri == null) { + result.error("INVALID_ARGS", "Missing 'uri'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + // MPV: Build loadfile command with options + val startSeconds = startPositionMs / 1000.0 + val options = mutableListOf() + options.add("start=$startSeconds") + if (!autoPlay) options.add("pause=yes") + headers?.forEach { (key, value) -> + options.add("http-header-fields-append=$key: $value") + } + val optionsStr = options.joinToString(",") + mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) + } else { + playerCore?.open(uri, headers, startPositionMs, autoPlay) + } + result.success(null) + } ?: result.error("NO_ACTIVITY", "Activity not available", null) + } + + private fun handlePlay(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("pause", "no") + } else { + playerCore?.play() + } + result.success(null) + } ?: result.success(null) + } + + private fun handlePause(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("pause", "yes") + } else { + playerCore?.pause() + } + result.success(null) + } ?: result.success(null) + } + + private fun handleStop(result: MethodChannel.Result) { + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.command(arrayOf("stop")) + mpvCore?.setVisible(false) + } else { + playerCore?.stop() + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSeek(call: MethodCall, result: MethodChannel.Result) { + val positionMs = call.argument("positionMs")?.toLong() + + if (positionMs == null) { + result.error("INVALID_ARGS", "Missing 'positionMs'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + val positionSeconds = positionMs / 1000.0 + mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + } else { + playerCore?.seekTo(positionMs) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) { + val volume = call.argument("volume")?.toFloat() + + if (volume == null) { + result.error("INVALID_ARGS", "Missing 'volume'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("volume", volume.toString()) + } else { + playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSetRate(call: MethodCall, result: MethodChannel.Result) { + val rate = call.argument("rate")?.toFloat() + + if (rate == null) { + result.error("INVALID_ARGS", "Missing 'rate'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + mpvCore?.setProperty("speed", rate.toString()) + } else { + playerCore?.setPlaybackSpeed(rate) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSelectAudioTrack(call: MethodCall, result: MethodChannel.Result) { + val trackId = call.argument("trackId") + + if (trackId == null) { + result.error("INVALID_ARGS", "Missing 'trackId'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + // MPV uses numeric track IDs - extract from string format + val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 + mpvCore?.setProperty("aid", numericId.toString()) + } else { + playerCore?.selectAudioTrack(trackId) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSelectSubtitleTrack(call: MethodCall, result: MethodChannel.Result) { + val trackId = call.argument("trackId") + + // trackId can be null or "no" to disable subtitles + activity?.runOnUiThread { + if (usingMpvFallback) { + if (trackId == null || trackId == "no") { + mpvCore?.setProperty("sid", "no") + } else { + val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 + mpvCore?.setProperty("sid", numericId.toString()) + } + } else { + playerCore?.selectSubtitleTrack(trackId) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleAddSubtitleTrack(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri") + val title = call.argument("title") + val language = call.argument("language") + val mimeType = call.argument("mimeType") + val select = call.argument("select") ?: false + + if (uri == null) { + result.error("INVALID_ARGS", "Missing 'uri'", null) + return + } + + activity?.runOnUiThread { + if (usingMpvFallback) { + val selectFlag = if (select) "select" else "auto" + mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) + } else { + playerCore?.addSubtitleTrack(uri, title, language, mimeType, select) + } + result.success(null) + } ?: result.success(null) + } + + private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { + val visible = call.argument("visible") + + if (visible == null) { + result.error("INVALID_ARGS", "Missing 'visible'", null) + return + } + + if (usingMpvFallback) { + mpvCore?.setVisible(visible) + } else { + playerCore?.setVisible(visible) + } + result.success(null) + } + + private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { + val fps = call.argument("fps")?.toFloat() ?: 0f + val duration = call.argument("duration")?.toLong() ?: 0L + + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration") + if (usingMpvFallback) { + mpvCore?.setVideoFrameRate(fps, duration) + } else { + playerCore?.setVideoFrameRate(fps, duration) + } + result.success(null) + } + + private fun handleClearVideoFrameRate(result: MethodChannel.Result) { + Log.d(TAG, "clearVideoFrameRate") + if (usingMpvFallback) { + mpvCore?.clearVideoFrameRate() + } else { + playerCore?.clearVideoFrameRate() + } + result.success(null) + } + + private fun handleRequestAudioFocus(result: MethodChannel.Result) { + Log.d(TAG, "requestAudioFocus") + val granted = if (usingMpvFallback) { + mpvCore?.requestAudioFocus() ?: false + } else { + playerCore?.requestAudioFocus() ?: false + } + result.success(granted) + } + + private fun handleAbandonAudioFocus(result: MethodChannel.Result) { + Log.d(TAG, "abandonAudioFocus") + if (usingMpvFallback) { + mpvCore?.abandonAudioFocus() + } else { + playerCore?.abandonAudioFocus() + } + result.success(null) + } + + // ExoPlayerDelegate + + override fun onPropertyChange(name: String, value: Any?) { + eventSink?.success( + mapOf( + "type" to "property", + "name" to name, + "value" to value + ) + ) + } + + override fun onEvent(name: String, data: Map?) { + val event = mutableMapOf( + "type" to "event", + "name" to name + ) + data?.let { event["data"] = it } + eventSink?.success(event) + } + + override fun onFormatUnsupported( + uri: String, + headers: Map?, + positionMs: Long, + errorMessage: String + ): Boolean { + val currentActivity = activity ?: return false + + Log.i(TAG, "Format unsupported, switching to MPV fallback at ${positionMs}ms") + + currentActivity.runOnUiThread { + try { + // Dispose ExoPlayer + playerCore?.dispose() + playerCore = null + + // Create and initialize MPV + mpvCore = MpvPlayerCore(currentActivity).apply { + delegate = this@ExoPlayerPlugin + } + val success = mpvCore?.initialize() ?: false + + if (!success) { + Log.e(TAG, "Failed to initialize MPV fallback") + onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) + return@runOnUiThread + } + + usingMpvFallback = true + + // Configure basic MPV properties for Plex playback + mpvCore?.setProperty("hwdec", "auto") + mpvCore?.setProperty("vo", "gpu") + mpvCore?.setProperty("ao", "audiotrack") + + // Setup property observers + mpvCore?.observeProperty("time-pos", "double") + mpvCore?.observeProperty("duration", "double") + mpvCore?.observeProperty("pause", "flag") + mpvCore?.observeProperty("paused-for-cache", "flag") + mpvCore?.observeProperty("demuxer-cache-time", "double") + mpvCore?.observeProperty("eof-reached", "flag") + mpvCore?.observeProperty("track-list", "string") + mpvCore?.observeProperty("aid", "string") + mpvCore?.observeProperty("sid", "string") + mpvCore?.observeProperty("volume", "double") + mpvCore?.observeProperty("speed", "double") + + // Show the MPV surface + mpvCore?.setVisible(true) + + // Load media at the same position + val startSeconds = positionMs / 1000.0 + val options = mutableListOf() + options.add("start=$startSeconds") + headers?.forEach { (key, value) -> + options.add("http-header-fields-append=$key: $value") + } + val optionsStr = options.joinToString(",") + mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) + + // Request audio focus + mpvCore?.requestAudioFocus() + + // Emit backend-switched event so Flutter can show notification + onEvent("backend-switched", null) + + Log.i(TAG, "Successfully switched to MPV fallback") + } catch (e: Exception) { + Log.e(TAG, "Failed to switch to MPV fallback", e) + onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) + } + } + + return true // Fallback is being handled + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt index cbd58641..a585e8af 100644 --- a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -8,8 +8,11 @@ import android.content.Context import android.content.res.Configuration import android.util.Rational import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.RenderMode +import io.flutter.embedding.android.TransparencyMode import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import com.edde746.plezy.exoplayer.ExoPlayerPlugin import com.edde746.plezy.mpv.MpvPlayerPlugin class MainActivity : FlutterActivity() { @@ -26,9 +29,21 @@ class MainActivity : FlutterActivity() { } } + override fun getRenderMode(): RenderMode { + // Use TextureView so Flutter doesn't occupy a SurfaceView layer. + // This allows the libass subtitle SurfaceView to sit between video and Flutter UI. + return RenderMode.texture + } + + override fun getTransparencyMode(): TransparencyMode { + // Keep Flutter transparent so video/subtitles are visible below. + return TransparencyMode.transparent + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) flutterEngine.plugins.add(MpvPlayerPlugin()) + flutterEngine.plugins.add(ExoPlayerPlugin()) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result -> when (call.method) { diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index a8bd35e8..b51cce52 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -94,6 +94,11 @@ "useGlobalHubsDescription": "Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.", "showServerNameOnHubs": "Servername bei Hubs anzeigen", "showServerNameOnHubsDescription": "Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.", + "playerBackend": "Player-Backend", + "exoPlayer": "ExoPlayer (Empfohlen)", + "exoPlayerDescription": "Android-nativer Player mit besserer Hardware-Unterstützung", + "mpv": "MPV", + "mpvDescription": "Erweiterter Player mit mehr Funktionen und ASS-Untertitel-Unterstützung", "hardwareDecoding": "Hardware-Decodierung", "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar", "bufferSize": "Puffergröße", @@ -336,7 +341,8 @@ "noItemsAvailable": "Keine Elemente verfügbar", "failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden", "failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente", - "failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}" + "failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}", + "switchingToCompatiblePlayer": "Wechsle zu kompatiblem Player..." }, "subtitlingStyling": { "stylingOptions": "Stiloptionen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 238c1ee1..032a6310 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -93,6 +93,11 @@ "useGlobalHubsDescription": "Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Show Server Name on Hubs", "showServerNameOnHubsDescription": "Always display the server name in hub titles. When off, only shows for duplicate hub names.", + "playerBackend": "Player Backend", + "exoPlayer": "ExoPlayer (Recommended)", + "exoPlayerDescription": "Android native player with better hardware support", + "mpv": "MPV", + "mpvDescription": "Advanced player with more features and ASS subtitle support", "hardwareDecoding": "Hardware Decoding", "hardwareDecodingDescription": "Use hardware acceleration when available", "bufferSize": "Buffer Size", @@ -335,7 +340,8 @@ "noItemsAvailable": "No items available", "failedToCreatePlayQueue": "Failed to create play queue", "failedToCreatePlayQueueNoItems": "Failed to create play queue - no items", - "failedPlayback": "Failed to ${action}: ${error}" + "failedPlayback": "Failed to ${action}: ${error}", + "switchingToCompatiblePlayer": "Switching to compatible player..." }, "subtitlingStyling": { "stylingOptions": "Styling Options", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4d299a81..1467f3b4 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -93,6 +93,11 @@ "useGlobalHubsDescription": "Afficher les hubs de la page d'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.", "showServerNameOnHubs": "Afficher le nom du serveur sur les hubs", "showServerNameOnHubsDescription": "Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s'affichent.", + "playerBackend": "Moteur de lecture", + "exoPlayer": "ExoPlayer (Recommandé)", + "exoPlayerDescription": "Lecteur natif Android avec meilleur support matériel", + "mpv": "MPV", + "mpvDescription": "Lecteur avancé avec plus de fonctionnalités et support des sous-titres ASS", "hardwareDecoding": "Décodage matériel", "hardwareDecodingDescription": "Utilisez l'accélération matérielle lorsqu'elle est disponible.", "bufferSize": "Taille du Buffer", @@ -335,7 +340,8 @@ "noItemsAvailable": "Aucun élément disponible", "failedToCreatePlayQueue": "Échec de la création de la file d'attente de lecture", "failedToCreatePlayQueueNoItems": "Échec de la création de la file d'attente de lecture - aucun élément", - "failedPlayback": "Echec de ${action}: ${error}" + "failedPlayback": "Echec de ${action}: ${error}", + "switchingToCompatiblePlayer": "Passage au lecteur compatible..." }, "subtitlingStyling": { "stylingOptions": "Options de style", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 111ed41e..9bb92ad2 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -94,6 +94,11 @@ "useGlobalHubsDescription": "Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.", "showServerNameOnHubs": "Mostra nome server sugli hub", "showServerNameOnHubsDescription": "Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.", + "playerBackend": "Motore di riproduzione", + "exoPlayer": "ExoPlayer (Consigliato)", + "exoPlayerDescription": "Lettore nativo Android con migliore supporto hardware", + "mpv": "MPV", + "mpvDescription": "Lettore avanzato con più funzionalità e supporto sottotitoli ASS", "hardwareDecoding": "Decodifica Hardware", "hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile", "bufferSize": "Dimensione buffer", @@ -336,7 +341,8 @@ "noItemsAvailable": "Nessun elemento disponibile", "failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione", "failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento", - "failedPlayback": "Impossibile ${action}: ${error}" + "failedPlayback": "Impossibile ${action}: ${error}", + "switchingToCompatiblePlayer": "Passaggio al lettore compatibile..." }, "subtitlingStyling": { "stylingOptions": "Opzioni stile", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index fa6115ef..15b81748 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -94,6 +94,11 @@ "useGlobalHubsDescription": "공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.", "showServerNameOnHubs": "허브에 서버 이름 표시", "showServerNameOnHubsDescription": "허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.", + "playerBackend": "플레이어 백엔드", + "exoPlayer": "ExoPlayer (권장)", + "exoPlayerDescription": "더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어", + "mpv": "MPV", + "mpvDescription": "더 많은 기능과 ASS 자막을 지원하는 고급 플레이어", "hardwareDecoding": "하드웨어 디코딩", "hardwareDecodingDescription": "가능한 경우 하드웨어 가속을 사용합니다", "bufferSize": "버퍼 크기", @@ -336,7 +341,8 @@ "noItemsAvailable": "사용 가능한 항목이 없습니다", "failedToCreatePlayQueue": "재생 대기열 생성 실패", "failedToCreatePlayQueueNoItems": "재생 대기열 생성 실패 - 항목 없음", - "failedPlayback": "${action}을(를) 수행할 수 없습니다: ${error}" + "failedPlayback": "${action}을(를) 수행할 수 없습니다: ${error}", + "switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중..." }, "subtitlingStyling": { "stylingOptions": "스타일 옵션", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 4baf043c..9a022cc3 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -94,6 +94,11 @@ "useGlobalHubsDescription": "Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.", "showServerNameOnHubs": "Servernaam tonen bij hubs", "showServerNameOnHubsDescription": "Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.", + "playerBackend": "Speler backend", + "exoPlayer": "ExoPlayer (Aanbevolen)", + "exoPlayerDescription": "Android-native speler met betere hardware-ondersteuning", + "mpv": "MPV", + "mpvDescription": "Geavanceerde speler met meer functies en ASS-ondertitelondersteuning", "hardwareDecoding": "Hardware decodering", "hardwareDecodingDescription": "Gebruik hardware versnelling indien beschikbaar", "bufferSize": "Buffer grootte", @@ -336,7 +341,8 @@ "noItemsAvailable": "Geen items beschikbaar", "failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken", "failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items", - "failedPlayback": "Afspelen van ${action} mislukt: ${error}" + "failedPlayback": "Afspelen van ${action} mislukt: ${error}", + "switchingToCompatiblePlayer": "Overschakelen naar compatibele speler..." }, "subtitlingStyling": { "stylingOptions": "Opmaak opties", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index a4600cee..6ff1b6a0 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 8 -/// Strings: 4372 (546 per locale) +/// Strings: 4420 (552 per locale) /// -/// Built on 2026-01-21 at 22:57 UTC +/// Built on 2026-01-22 at 23:24 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 1e5ffa1a..2ddda2b8 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -207,6 +207,11 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.'; @override String get showServerNameOnHubs => 'Servername bei Hubs anzeigen'; @override String get showServerNameOnHubsDescription => 'Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.'; + @override String get playerBackend => 'Player-Backend'; + @override String get exoPlayer => 'ExoPlayer (Empfohlen)'; + @override String get exoPlayerDescription => 'Android-nativer Player mit besserer Hardware-Unterstützung'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => 'Erweiterter Player mit mehr Funktionen und ASS-Untertitel-Unterstützung'; @override String get hardwareDecoding => 'Hardware-Decodierung'; @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, sofern verfügbar'; @override String get bufferSize => 'Puffergröße'; @@ -496,6 +501,7 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => 'Wiedergabewarteschlange konnte nicht erstellt werden'; @override String get failedToCreatePlayQueueNoItems => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente'; @override String failedPlayback({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}'; + @override String get switchingToCompatiblePlayer => 'Wechsle zu kompatiblem Player...'; } // Path: subtitlingStyling @@ -1038,6 +1044,11 @@ extension on TranslationsDe { 'settings.useGlobalHubsDescription' => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.', 'settings.showServerNameOnHubs' => 'Servername bei Hubs anzeigen', 'settings.showServerNameOnHubsDescription' => 'Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.', + 'settings.playerBackend' => 'Player-Backend', + 'settings.exoPlayer' => 'ExoPlayer (Empfohlen)', + 'settings.exoPlayerDescription' => 'Android-nativer Player mit besserer Hardware-Unterstützung', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => 'Erweiterter Player mit mehr Funktionen und ASS-Untertitel-Unterstützung', 'settings.hardwareDecoding' => 'Hardware-Decodierung', 'settings.hardwareDecodingDescription' => 'Hardwarebeschleunigung verwenden, sofern verfügbar', 'settings.bufferSize' => 'Puffergröße', @@ -1257,6 +1268,7 @@ extension on TranslationsDe { 'messages.failedToCreatePlayQueue' => 'Wiedergabewarteschlange konnte nicht erstellt werden', 'messages.failedToCreatePlayQueueNoItems' => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}', + 'messages.switchingToCompatiblePlayer' => 'Wechsle zu kompatiblem Player...', 'subtitlingStyling.stylingOptions' => 'Stiloptionen', 'subtitlingStyling.fontSize' => 'Schriftgröße', 'subtitlingStyling.textColor' => 'Textfarbe', @@ -1460,14 +1472,14 @@ extension on TranslationsDe { 'collections.removeFromCollectionError' => ({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}', 'watchTogether.title' => 'Gemeinsam Schauen', 'watchTogether.description' => 'Inhalte synchron mit Freunden und Familie schauen', + _ => null, + } ?? switch (path) { 'watchTogether.createSession' => 'Sitzung Erstellen', 'watchTogether.creating' => 'Erstellen...', 'watchTogether.joinSession' => 'Sitzung Beitreten', 'watchTogether.joining' => 'Beitreten...', 'watchTogether.controlMode' => 'Steuerungsmodus', 'watchTogether.controlModeQuestion' => 'Wer kann die Wiedergabe steuern?', - _ => null, - } ?? switch (path) { 'watchTogether.hostOnly' => 'Nur Host', 'watchTogether.anyone' => 'Alle', 'watchTogether.hostingSession' => 'Sitzung Hosten', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 1bec1e85..a25654ff 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -376,6 +376,21 @@ class TranslationsSettingsEn { /// en: 'Always display the server name in hub titles. When off, only shows for duplicate hub names.' String get showServerNameOnHubsDescription => 'Always display the server name in hub titles. When off, only shows for duplicate hub names.'; + /// en: 'Player Backend' + String get playerBackend => 'Player Backend'; + + /// en: 'ExoPlayer (Recommended)' + String get exoPlayer => 'ExoPlayer (Recommended)'; + + /// en: 'Android native player with better hardware support' + String get exoPlayerDescription => 'Android native player with better hardware support'; + + /// en: 'MPV' + String get mpv => 'MPV'; + + /// en: 'Advanced player with more features and ASS subtitle support' + String get mpvDescription => 'Advanced player with more features and ASS subtitle support'; + /// en: 'Hardware Decoding' String get hardwareDecoding => 'Hardware Decoding'; @@ -1060,6 +1075,9 @@ class TranslationsMessagesEn { /// en: 'Failed to ${action}: ${error}' String failedPlayback({required Object action, required Object error}) => 'Failed to ${action}: ${error}'; + + /// en: 'Switching to compatible player...' + String get switchingToCompatiblePlayer => 'Switching to compatible player...'; } // Path: subtitlingStyling @@ -2131,6 +2149,11 @@ extension on Translations { 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Show Server Name on Hubs', 'settings.showServerNameOnHubsDescription' => 'Always display the server name in hub titles. When off, only shows for duplicate hub names.', + 'settings.playerBackend' => 'Player Backend', + 'settings.exoPlayer' => 'ExoPlayer (Recommended)', + 'settings.exoPlayerDescription' => 'Android native player with better hardware support', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => 'Advanced player with more features and ASS subtitle support', 'settings.hardwareDecoding' => 'Hardware Decoding', 'settings.hardwareDecodingDescription' => 'Use hardware acceleration when available', 'settings.bufferSize' => 'Buffer Size', @@ -2350,6 +2373,7 @@ extension on Translations { 'messages.failedToCreatePlayQueue' => 'Failed to create play queue', 'messages.failedToCreatePlayQueueNoItems' => 'Failed to create play queue - no items', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Failed to ${action}: ${error}', + 'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...', 'subtitlingStyling.stylingOptions' => 'Styling Options', 'subtitlingStyling.fontSize' => 'Font Size', 'subtitlingStyling.textColor' => 'Text Color', @@ -2554,14 +2578,14 @@ extension on Translations { 'watchTogether.hostControls' => 'Host controls', 'watchTogether.anyoneControls' => 'Anyone controls', 'watchTogether.participants' => 'Participants', + _ => null, + } ?? switch (path) { 'watchTogether.host' => 'Host', 'watchTogether.hostBadge' => 'HOST', 'watchTogether.youAreHost' => 'You are the host', 'watchTogether.watchingWithOthers' => 'Watching with others', 'watchTogether.endSession' => 'End Session', 'watchTogether.leaveSession' => 'Leave Session', - _ => null, - } ?? switch (path) { 'watchTogether.endSessionQuestion' => 'End Session?', 'watchTogether.leaveSessionQuestion' => 'Leave Session?', 'watchTogether.endSessionConfirm' => 'This will end the session for all participants.', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 324f9eed..fabe0c5e 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -206,6 +206,11 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => 'Afficher les hubs de la page d\'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.'; @override String get showServerNameOnHubs => 'Afficher le nom du serveur sur les hubs'; @override String get showServerNameOnHubsDescription => 'Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s\'affichent.'; + @override String get playerBackend => 'Moteur de lecture'; + @override String get exoPlayer => 'ExoPlayer (Recommandé)'; + @override String get exoPlayerDescription => 'Lecteur natif Android avec meilleur support matériel'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => 'Lecteur avancé avec plus de fonctionnalités et support des sous-titres ASS'; @override String get hardwareDecoding => 'Décodage matériel'; @override String get hardwareDecodingDescription => 'Utilisez l\'accélération matérielle lorsqu\'elle est disponible.'; @override String get bufferSize => 'Taille du Buffer'; @@ -495,6 +500,7 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => 'Échec de la création de la file d\'attente de lecture'; @override String get failedToCreatePlayQueueNoItems => 'Échec de la création de la file d\'attente de lecture - aucun élément'; @override String failedPlayback({required Object action, required Object error}) => 'Echec de ${action}: ${error}'; + @override String get switchingToCompatiblePlayer => 'Passage au lecteur compatible...'; } // Path: subtitlingStyling @@ -1035,6 +1041,11 @@ extension on TranslationsFr { 'settings.useGlobalHubsDescription' => 'Afficher les hubs de la page d\'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.', 'settings.showServerNameOnHubs' => 'Afficher le nom du serveur sur les hubs', 'settings.showServerNameOnHubsDescription' => 'Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s\'affichent.', + 'settings.playerBackend' => 'Moteur de lecture', + 'settings.exoPlayer' => 'ExoPlayer (Recommandé)', + 'settings.exoPlayerDescription' => 'Lecteur natif Android avec meilleur support matériel', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => 'Lecteur avancé avec plus de fonctionnalités et support des sous-titres ASS', 'settings.hardwareDecoding' => 'Décodage matériel', 'settings.hardwareDecodingDescription' => 'Utilisez l\'accélération matérielle lorsqu\'elle est disponible.', 'settings.bufferSize' => 'Taille du Buffer', @@ -1254,6 +1265,7 @@ extension on TranslationsFr { 'messages.failedToCreatePlayQueue' => 'Échec de la création de la file d\'attente de lecture', 'messages.failedToCreatePlayQueueNoItems' => 'Échec de la création de la file d\'attente de lecture - aucun élément', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Echec de ${action}: ${error}', + 'messages.switchingToCompatiblePlayer' => 'Passage au lecteur compatible...', 'subtitlingStyling.stylingOptions' => 'Options de style', 'subtitlingStyling.fontSize' => 'Taille de la police', 'subtitlingStyling.textColor' => 'Couleur du texte', @@ -1458,14 +1470,14 @@ extension on TranslationsFr { 'watchTogether.hostControls' => 'Commandes de l\'hôte', 'watchTogether.anyoneControls' => 'Tout le monde contrôle', 'watchTogether.participants' => 'Participants', + _ => null, + } ?? switch (path) { 'watchTogether.host' => 'Hôte', 'watchTogether.hostBadge' => 'HOST', 'watchTogether.youAreHost' => 'Vous êtes l\'hôte', 'watchTogether.watchingWithOthers' => 'Regarder avec d\'autres personnes', 'watchTogether.endSession' => 'Fin de session', 'watchTogether.leaveSession' => 'Quitter la session', - _ => null, - } ?? switch (path) { 'watchTogether.endSessionQuestion' => 'Terminer la session ?', 'watchTogether.leaveSessionQuestion' => 'Quitter la session ?', 'watchTogether.endSessionConfirm' => 'Cela mettra fin à la session pour tous les participants.', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 34a367a2..c7286dc3 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -207,6 +207,11 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.'; @override String get showServerNameOnHubs => 'Mostra nome server sugli hub'; @override String get showServerNameOnHubsDescription => 'Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.'; + @override String get playerBackend => 'Motore di riproduzione'; + @override String get exoPlayer => 'ExoPlayer (Consigliato)'; + @override String get exoPlayerDescription => 'Lettore nativo Android con migliore supporto hardware'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => 'Lettore avanzato con più funzionalità e supporto sottotitoli ASS'; @override String get hardwareDecoding => 'Decodifica Hardware'; @override String get hardwareDecodingDescription => 'Utilizza l\'accelerazione hardware quando disponibile'; @override String get bufferSize => 'Dimensione buffer'; @@ -496,6 +501,7 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => 'Impossibile creare la coda di riproduzione'; @override String get failedToCreatePlayQueueNoItems => 'Impossibile creare la coda di riproduzione - nessun elemento'; @override String failedPlayback({required Object action, required Object error}) => 'Impossibile ${action}: ${error}'; + @override String get switchingToCompatiblePlayer => 'Passaggio al lettore compatibile...'; } // Path: subtitlingStyling @@ -1038,6 +1044,11 @@ extension on TranslationsIt { 'settings.useGlobalHubsDescription' => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.', 'settings.showServerNameOnHubs' => 'Mostra nome server sugli hub', 'settings.showServerNameOnHubsDescription' => 'Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.', + 'settings.playerBackend' => 'Motore di riproduzione', + 'settings.exoPlayer' => 'ExoPlayer (Consigliato)', + 'settings.exoPlayerDescription' => 'Lettore nativo Android con migliore supporto hardware', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => 'Lettore avanzato con più funzionalità e supporto sottotitoli ASS', 'settings.hardwareDecoding' => 'Decodifica Hardware', 'settings.hardwareDecodingDescription' => 'Utilizza l\'accelerazione hardware quando disponibile', 'settings.bufferSize' => 'Dimensione buffer', @@ -1257,6 +1268,7 @@ extension on TranslationsIt { 'messages.failedToCreatePlayQueue' => 'Impossibile creare la coda di riproduzione', 'messages.failedToCreatePlayQueueNoItems' => 'Impossibile creare la coda di riproduzione - nessun elemento', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Impossibile ${action}: ${error}', + 'messages.switchingToCompatiblePlayer' => 'Passaggio al lettore compatibile...', 'subtitlingStyling.stylingOptions' => 'Opzioni stile', 'subtitlingStyling.fontSize' => 'Dimensione', 'subtitlingStyling.textColor' => 'Colore testo', @@ -1460,14 +1472,14 @@ extension on TranslationsIt { 'collections.removeFromCollectionError' => ({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}', 'watchTogether.title' => 'Guarda Insieme', 'watchTogether.description' => 'Guarda contenuti in sincronia con amici e familiari', + _ => null, + } ?? switch (path) { 'watchTogether.createSession' => 'Crea Sessione', 'watchTogether.creating' => 'Creazione...', 'watchTogether.joinSession' => 'Unisciti alla Sessione', 'watchTogether.joining' => 'Connessione...', 'watchTogether.controlMode' => 'Modalità di Controllo', 'watchTogether.controlModeQuestion' => 'Chi può controllare la riproduzione?', - _ => null, - } ?? switch (path) { 'watchTogether.hostOnly' => 'Solo Host', 'watchTogether.anyone' => 'Tutti', 'watchTogether.hostingSession' => 'Hosting Sessione', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index a778c89a..a9f9a4c1 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -207,6 +207,11 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.'; @override String get showServerNameOnHubs => '허브에 서버 이름 표시'; @override String get showServerNameOnHubsDescription => '허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.'; + @override String get playerBackend => '플레이어 백엔드'; + @override String get exoPlayer => 'ExoPlayer (권장)'; + @override String get exoPlayerDescription => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => '더 많은 기능과 ASS 자막을 지원하는 고급 플레이어'; @override String get hardwareDecoding => '하드웨어 디코딩'; @override String get hardwareDecodingDescription => '가능한 경우 하드웨어 가속을 사용합니다'; @override String get bufferSize => '버퍼 크기'; @@ -496,6 +501,7 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => '재생 대기열 생성 실패'; @override String get failedToCreatePlayQueueNoItems => '재생 대기열 생성 실패 - 항목 없음'; @override String failedPlayback({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}'; + @override String get switchingToCompatiblePlayer => '호환되는 플레이어로 전환 중...'; } // Path: subtitlingStyling @@ -1038,6 +1044,11 @@ extension on TranslationsKo { 'settings.useGlobalHubsDescription' => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.', 'settings.showServerNameOnHubs' => '허브에 서버 이름 표시', 'settings.showServerNameOnHubsDescription' => '허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.', + 'settings.playerBackend' => '플레이어 백엔드', + 'settings.exoPlayer' => 'ExoPlayer (권장)', + 'settings.exoPlayerDescription' => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => '더 많은 기능과 ASS 자막을 지원하는 고급 플레이어', 'settings.hardwareDecoding' => '하드웨어 디코딩', 'settings.hardwareDecodingDescription' => '가능한 경우 하드웨어 가속을 사용합니다', 'settings.bufferSize' => '버퍼 크기', @@ -1257,6 +1268,7 @@ extension on TranslationsKo { 'messages.failedToCreatePlayQueue' => '재생 대기열 생성 실패', 'messages.failedToCreatePlayQueueNoItems' => '재생 대기열 생성 실패 - 항목 없음', 'messages.failedPlayback' => ({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}', + 'messages.switchingToCompatiblePlayer' => '호환되는 플레이어로 전환 중...', 'subtitlingStyling.stylingOptions' => '스타일 옵션', 'subtitlingStyling.fontSize' => '글자 크기', 'subtitlingStyling.textColor' => '텍스트 색상', @@ -1460,14 +1472,14 @@ extension on TranslationsKo { 'watchTogether.hostControlsPlayback' => '호스트 재생 제어', 'watchTogether.anyoneCanControl' => '누구나 재생 제어 가능', 'watchTogether.hostControls' => '호스트 제어', + _ => null, + } ?? switch (path) { 'watchTogether.anyoneControls' => '누구나 제어', 'watchTogether.participants' => '참가자', 'watchTogether.host' => '호스트', 'watchTogether.hostBadge' => '호스트', 'watchTogether.youAreHost' => '당신은 호스트 입니다', 'watchTogether.watchingWithOthers' => '다른 사람과 함께 시청 중', - _ => null, - } ?? switch (path) { 'watchTogether.endSession' => '세션 종료', 'watchTogether.leaveSession' => '세션 탈퇴', 'watchTogether.endSessionQuestion' => '세션을 종료 하시겠습니까?', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 4b17fe06..dd4f6dcc 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -207,6 +207,11 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.'; @override String get showServerNameOnHubs => 'Servernaam tonen bij hubs'; @override String get showServerNameOnHubsDescription => 'Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.'; + @override String get playerBackend => 'Speler backend'; + @override String get exoPlayer => 'ExoPlayer (Aanbevolen)'; + @override String get exoPlayerDescription => 'Android-native speler met betere hardware-ondersteuning'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => 'Geavanceerde speler met meer functies en ASS-ondertitelondersteuning'; @override String get hardwareDecoding => 'Hardware decodering'; @override String get hardwareDecodingDescription => 'Gebruik hardware versnelling indien beschikbaar'; @override String get bufferSize => 'Buffer grootte'; @@ -496,6 +501,7 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => 'Kan afspeelwachtrij niet maken'; @override String get failedToCreatePlayQueueNoItems => 'Kan afspeelwachtrij niet maken - geen items'; @override String failedPlayback({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}'; + @override String get switchingToCompatiblePlayer => 'Overschakelen naar compatibele speler...'; } // Path: subtitlingStyling @@ -1038,6 +1044,11 @@ extension on TranslationsNl { 'settings.useGlobalHubsDescription' => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.', 'settings.showServerNameOnHubs' => 'Servernaam tonen bij hubs', 'settings.showServerNameOnHubsDescription' => 'Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.', + 'settings.playerBackend' => 'Speler backend', + 'settings.exoPlayer' => 'ExoPlayer (Aanbevolen)', + 'settings.exoPlayerDescription' => 'Android-native speler met betere hardware-ondersteuning', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => 'Geavanceerde speler met meer functies en ASS-ondertitelondersteuning', 'settings.hardwareDecoding' => 'Hardware decodering', 'settings.hardwareDecodingDescription' => 'Gebruik hardware versnelling indien beschikbaar', 'settings.bufferSize' => 'Buffer grootte', @@ -1257,6 +1268,7 @@ extension on TranslationsNl { 'messages.failedToCreatePlayQueue' => 'Kan afspeelwachtrij niet maken', 'messages.failedToCreatePlayQueueNoItems' => 'Kan afspeelwachtrij niet maken - geen items', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}', + 'messages.switchingToCompatiblePlayer' => 'Overschakelen naar compatibele speler...', 'subtitlingStyling.stylingOptions' => 'Opmaak opties', 'subtitlingStyling.fontSize' => 'Lettergrootte', 'subtitlingStyling.textColor' => 'Tekstkleur', @@ -1460,14 +1472,14 @@ extension on TranslationsNl { 'collections.removeFromCollectionError' => ({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}', 'watchTogether.title' => 'Samen Kijken', 'watchTogether.description' => 'Kijk synchroon met vrienden en familie', + _ => null, + } ?? switch (path) { 'watchTogether.createSession' => 'Sessie Maken', 'watchTogether.creating' => 'Maken...', 'watchTogether.joinSession' => 'Sessie Deelnemen', 'watchTogether.joining' => 'Deelnemen...', 'watchTogether.controlMode' => 'Controlemodus', 'watchTogether.controlModeQuestion' => 'Wie kan het afspelen bedienen?', - _ => null, - } ?? switch (path) { 'watchTogether.hostOnly' => 'Alleen Host', 'watchTogether.anyone' => 'Iedereen', 'watchTogether.hostingSession' => 'Sessie Hosten', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 53fa5a23..8c9e85b0 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -207,6 +207,11 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.'; @override String get showServerNameOnHubs => 'Visa servernamn på hubbar'; @override String get showServerNameOnHubsDescription => 'Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.'; + @override String get playerBackend => 'Spelarmotor'; + @override String get exoPlayer => 'ExoPlayer (Rekommenderad)'; + @override String get exoPlayerDescription => 'Android-nativ spelare med bättre hårdvarustöd'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => 'Avancerad spelare med fler funktioner och ASS-undertextstöd'; @override String get hardwareDecoding => 'Hårdvaruavkodning'; @override String get hardwareDecodingDescription => 'Använd hårdvaruacceleration när tillgängligt'; @override String get bufferSize => 'Bufferstorlek'; @@ -496,6 +501,7 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => 'Det gick inte att skapa uppspelningskö'; @override String get failedToCreatePlayQueueNoItems => 'Det gick inte att skapa uppspelningskö – inga objekt'; @override String failedPlayback({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}'; + @override String get switchingToCompatiblePlayer => 'Byter till kompatibel spelare...'; } // Path: subtitlingStyling @@ -1038,6 +1044,11 @@ extension on TranslationsSv { 'settings.useGlobalHubsDescription' => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.', 'settings.showServerNameOnHubs' => 'Visa servernamn på hubbar', 'settings.showServerNameOnHubsDescription' => 'Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.', + 'settings.playerBackend' => 'Spelarmotor', + 'settings.exoPlayer' => 'ExoPlayer (Rekommenderad)', + 'settings.exoPlayerDescription' => 'Android-nativ spelare med bättre hårdvarustöd', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => 'Avancerad spelare med fler funktioner och ASS-undertextstöd', 'settings.hardwareDecoding' => 'Hårdvaruavkodning', 'settings.hardwareDecodingDescription' => 'Använd hårdvaruacceleration när tillgängligt', 'settings.bufferSize' => 'Bufferstorlek', @@ -1257,6 +1268,7 @@ extension on TranslationsSv { 'messages.failedToCreatePlayQueue' => 'Det gick inte att skapa uppspelningskö', 'messages.failedToCreatePlayQueueNoItems' => 'Det gick inte att skapa uppspelningskö – inga objekt', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}', + 'messages.switchingToCompatiblePlayer' => 'Byter till kompatibel spelare...', 'subtitlingStyling.stylingOptions' => 'Stilalternativ', 'subtitlingStyling.fontSize' => 'Teckenstorlek', 'subtitlingStyling.textColor' => 'Textfärg', @@ -1460,14 +1472,14 @@ extension on TranslationsSv { 'collections.removeFromCollectionError' => ({required Object error}) => 'Fel vid borttagning från samling: ${error}', 'watchTogether.title' => 'Titta Tillsammans', 'watchTogether.description' => 'Titta på innehåll synkroniserat med vänner och familj', + _ => null, + } ?? switch (path) { 'watchTogether.createSession' => 'Skapa Session', 'watchTogether.creating' => 'Skapar...', 'watchTogether.joinSession' => 'Gå med i Session', 'watchTogether.joining' => 'Ansluter...', 'watchTogether.controlMode' => 'Kontrollläge', 'watchTogether.controlModeQuestion' => 'Vem kan styra uppspelningen?', - _ => null, - } ?? switch (path) { 'watchTogether.hostOnly' => 'Endast Värd', 'watchTogether.anyone' => 'Alla', 'watchTogether.hostingSession' => 'Värd för Session', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index f9184bcc..2e416034 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -207,6 +207,11 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { @override String get useGlobalHubsDescription => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。'; @override String get showServerNameOnHubs => '在推荐栏显示服务器名称'; @override String get showServerNameOnHubsDescription => '始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。'; + @override String get playerBackend => '播放器引擎'; + @override String get exoPlayer => 'ExoPlayer(推荐)'; + @override String get exoPlayerDescription => 'Android 原生播放器,硬件支持更好'; + @override String get mpv => 'MPV'; + @override String get mpvDescription => '功能更多的高级播放器,支持 ASS 字幕'; @override String get hardwareDecoding => '硬件解码'; @override String get hardwareDecodingDescription => '如果可用,使用硬件加速'; @override String get bufferSize => '缓冲区大小'; @@ -496,6 +501,7 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn { @override String get failedToCreatePlayQueue => '创建播放队列失败'; @override String get failedToCreatePlayQueueNoItems => '创建播放队列失败 - 没有项目'; @override String failedPlayback({required Object action, required Object error}) => '无法${action}: ${error}'; + @override String get switchingToCompatiblePlayer => '正在切换到兼容的播放器...'; } // Path: subtitlingStyling @@ -1038,6 +1044,11 @@ extension on TranslationsZh { 'settings.useGlobalHubsDescription' => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。', 'settings.showServerNameOnHubs' => '在推荐栏显示服务器名称', 'settings.showServerNameOnHubsDescription' => '始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。', + 'settings.playerBackend' => '播放器引擎', + 'settings.exoPlayer' => 'ExoPlayer(推荐)', + 'settings.exoPlayerDescription' => 'Android 原生播放器,硬件支持更好', + 'settings.mpv' => 'MPV', + 'settings.mpvDescription' => '功能更多的高级播放器,支持 ASS 字幕', 'settings.hardwareDecoding' => '硬件解码', 'settings.hardwareDecodingDescription' => '如果可用,使用硬件加速', 'settings.bufferSize' => '缓冲区大小', @@ -1257,6 +1268,7 @@ extension on TranslationsZh { 'messages.failedToCreatePlayQueue' => '创建播放队列失败', 'messages.failedToCreatePlayQueueNoItems' => '创建播放队列失败 - 没有项目', 'messages.failedPlayback' => ({required Object action, required Object error}) => '无法${action}: ${error}', + 'messages.switchingToCompatiblePlayer' => '正在切换到兼容的播放器...', 'subtitlingStyling.stylingOptions' => '样式选项', 'subtitlingStyling.fontSize' => '字号', 'subtitlingStyling.textColor' => '文本颜色', @@ -1460,14 +1472,14 @@ extension on TranslationsZh { 'collections.removeFromCollectionError' => ({required Object error}) => '从合集移除时出错:${error}', 'watchTogether.title' => '一起看', 'watchTogether.description' => '与朋友和家人同步观看内容', + _ => null, + } ?? switch (path) { 'watchTogether.createSession' => '创建会话', 'watchTogether.creating' => '创建中...', 'watchTogether.joinSession' => '加入会话', 'watchTogether.joining' => '加入中...', 'watchTogether.controlMode' => '控制模式', 'watchTogether.controlModeQuestion' => '谁可以控制播放?', - _ => null, - } ?? switch (path) { 'watchTogether.hostOnly' => '仅主持人', 'watchTogether.anyone' => '任何人', 'watchTogether.hostingSession' => '主持会话', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 407b3655..76cbd33a 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -94,6 +94,11 @@ "useGlobalHubsDescription": "Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.", "showServerNameOnHubs": "Visa servernamn på hubbar", "showServerNameOnHubsDescription": "Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.", + "playerBackend": "Spelarmotor", + "exoPlayer": "ExoPlayer (Rekommenderad)", + "exoPlayerDescription": "Android-nativ spelare med bättre hårdvarustöd", + "mpv": "MPV", + "mpvDescription": "Avancerad spelare med fler funktioner och ASS-undertextstöd", "hardwareDecoding": "Hårdvaruavkodning", "hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt", "bufferSize": "Bufferstorlek", @@ -336,7 +341,8 @@ "noItemsAvailable": "Inga objekt tillgängliga", "failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö", "failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt", - "failedPlayback": "Kunde inte ${action}: ${error}" + "failedPlayback": "Kunde inte ${action}: ${error}", + "switchingToCompatiblePlayer": "Byter till kompatibel spelare..." }, "subtitlingStyling": { "stylingOptions": "Stilalternativ", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 59bc3a22..7b5af3ae 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -94,6 +94,11 @@ "useGlobalHubsDescription": "显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。", "showServerNameOnHubs": "在推荐栏显示服务器名称", "showServerNameOnHubsDescription": "始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。", + "playerBackend": "播放器引擎", + "exoPlayer": "ExoPlayer(推荐)", + "exoPlayerDescription": "Android 原生播放器,硬件支持更好", + "mpv": "MPV", + "mpvDescription": "功能更多的高级播放器,支持 ASS 字幕", "hardwareDecoding": "硬件解码", "hardwareDecodingDescription": "如果可用,使用硬件加速", "bufferSize": "缓冲区大小", @@ -336,7 +341,8 @@ "noItemsAvailable": "没有可用的项目", "failedToCreatePlayQueue": "创建播放队列失败", "failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目", - "failedPlayback": "无法${action}: ${error}" + "failedPlayback": "无法${action}: ${error}", + "switchingToCompatiblePlayer": "正在切换到兼容的播放器..." }, "subtitlingStyling": { "stylingOptions": "样式选项", diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 146a20b1..bce87747 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -1,6 +1,7 @@ import 'dart:io' show Platform; import '../models.dart'; +import 'player_android.dart'; import 'player_native.dart'; import 'player_state.dart'; import 'player_streams.dart'; @@ -229,11 +230,25 @@ abstract class Player { /// Creates a new player instance. /// /// Returns a platform-specific implementation: - /// - macOS/iOS/Android: [PlayerNative] using MPVKit/libmpv with texture rendering + /// - macOS/iOS: [PlayerNative] using MPVKit/libmpv with Metal rendering + /// - Android: [PlayerAndroid] using ExoPlayer (default) or [PlayerNative] using MPV (fallback) /// - Windows: [PlayerWindows] using libmpv with native window embedding /// - Linux: [PlayerLinux] using libmpv with OpenGL rendering via GtkGLArea - factory Player() { - if (Platform.isMacOS || Platform.isIOS || Platform.isAndroid) { + /// + /// On Android, pass [useExoPlayer] to override the default: + /// - true: Use ExoPlayer (default, better hardware support) + /// - false: Use MPV (more features, ASS subtitle rendering) + factory Player({bool? useExoPlayer}) { + if (Platform.isAndroid) { + // Default to ExoPlayer on Android, with MPV as fallback + // The caller should pass useExoPlayer based on SettingsService.getUseExoPlayer() + final useExo = useExoPlayer ?? true; + if (useExo) { + return PlayerAndroid(); // ExoPlayer (default) + } + return PlayerNative(); // MPV fallback + } + if (Platform.isMacOS || Platform.isIOS) { return PlayerNative(); } if (Platform.isWindows) { diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart new file mode 100644 index 00000000..099d41d8 --- /dev/null +++ b/lib/mpv/player/player_android.dart @@ -0,0 +1,637 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import '../../utils/app_logger.dart'; +import '../models.dart'; +import 'player.dart'; +import 'player_state.dart'; +import 'player_streams.dart'; + +/// Android implementation of [Player] using ExoPlayer. +/// Provides hardware-accelerated playback with ASS subtitle support via libass-android. +class PlayerAndroid implements Player { + static const _methodChannel = MethodChannel('com.plezy/exo_player'); + static const _eventChannel = EventChannel('com.plezy/exo_player/events'); + + PlayerState _state = const PlayerState(); + + @override + PlayerState get state => _state; + + late final PlayerStreams _streams; + + @override + PlayerStreams get streams => _streams; + + @override + int? get textureId => null; // Uses SurfaceView, not Flutter texture + + // Stream controllers + final _playingController = StreamController.broadcast(); + final _completedController = StreamController.broadcast(); + final _bufferingController = StreamController.broadcast(); + final _positionController = StreamController.broadcast(); + final _durationController = StreamController.broadcast(); + final _bufferController = StreamController.broadcast(); + final _volumeController = StreamController.broadcast(); + final _rateController = StreamController.broadcast(); + final _tracksController = StreamController.broadcast(); + final _trackController = StreamController.broadcast(); + final _logController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _audioDeviceController = StreamController.broadcast(); + final _audioDevicesController = StreamController>.broadcast(); + final _playbackRestartController = StreamController.broadcast(); + final _backendSwitchedController = StreamController.broadcast(); + + StreamSubscription? _eventSubscription; + bool _disposed = false; + bool _initialized = false; + + PlayerAndroid() { + _streams = PlayerStreams( + playing: _playingController.stream, + completed: _completedController.stream, + buffering: _bufferingController.stream, + position: _positionController.stream, + duration: _durationController.stream, + buffer: _bufferController.stream, + volume: _volumeController.stream, + rate: _rateController.stream, + tracks: _tracksController.stream, + track: _trackController.stream, + log: _logController.stream, + error: _errorController.stream, + audioDevice: _audioDeviceController.stream, + audioDevices: _audioDevicesController.stream, + playbackRestart: _playbackRestartController.stream, + backendSwitched: _backendSwitchedController.stream, + ); + + _setupEventListener(); + + // Forward logs to app logger + _logController.stream.listen(_forwardToAppLogger); + } + + void _forwardToAppLogger(PlayerLog log) { + final message = '[ExoPlayer:${log.prefix}] ${log.text}'.trimRight(); + switch (log.level) { + case PlayerLogLevel.fatal: + case PlayerLogLevel.error: + appLogger.e(message); + case PlayerLogLevel.warn: + appLogger.w(message); + case PlayerLogLevel.info: + case PlayerLogLevel.verbose: + appLogger.i(message); + case PlayerLogLevel.debug: + case PlayerLogLevel.trace: + appLogger.d(message); + case PlayerLogLevel.none: + break; + } + } + + void _setupEventListener() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + _handleEvent, + onError: (error) { + _errorController.add(error.toString()); + }, + ); + } + + void _handleEvent(dynamic event) { + if (event is! Map) return; + + final type = event['type'] as String?; + final name = event['name'] as String?; + + if (type == 'property' && name != null) { + _handlePropertyChange(name, event['value']); + } else if (type == 'event' && name != null) { + _handlePlayerEvent(name, event['data'] as Map?); + } + } + + void _handlePropertyChange(String name, dynamic value) { + switch (name) { + case 'pause': + final playing = value == false; + _state = _state.copyWith(playing: playing); + _playingController.add(playing); + break; + + case 'eof-reached': + final completed = value == true; + _state = _state.copyWith(completed: completed); + _completedController.add(completed); + break; + + case 'paused-for-cache': + final buffering = value == true; + _state = _state.copyWith(buffering: buffering); + _bufferingController.add(buffering); + break; + + case 'time-pos': + if (value is num) { + final position = Duration(milliseconds: (value * 1000).toInt()); + _state = _state.copyWith(position: position); + _positionController.add(position); + } + break; + + case 'duration': + if (value is num) { + final duration = Duration(milliseconds: (value * 1000).toInt()); + _state = _state.copyWith(duration: duration); + _durationController.add(duration); + } + break; + + case 'demuxer-cache-time': + if (value is num) { + final buffer = Duration(milliseconds: (value * 1000).toInt()); + _state = _state.copyWith(buffer: buffer); + _bufferController.add(buffer); + } + break; + + case 'volume': + if (value is num) { + final volume = value.toDouble(); + _state = _state.copyWith(volume: volume); + _volumeController.add(volume); + } + break; + + case 'speed': + if (value is num) { + final rate = value.toDouble(); + _state = _state.copyWith(rate: rate); + _rateController.add(rate); + } + break; + + case 'track-list': + List? trackList; + if (value is List) { + trackList = value; + } else if (value is String && value.isNotEmpty) { + // MPV sends track-list as JSON string after fallback + try { + final parsed = jsonDecode(value); + if (parsed is List) trackList = parsed; + } catch (_) { + // Ignore parse errors + } + } + if (trackList != null) { + final tracks = _parseTrackList(trackList); + _state = _state.copyWith(tracks: tracks); + _tracksController.add(tracks); + } + break; + + case 'aid': + _updateSelectedAudioTrack(value); + break; + + case 'sid': + _updateSelectedSubtitleTrack(value); + break; + } + } + + void _handlePlayerEvent(String name, Map? data) { + switch (name) { + case 'end-file': + final reason = data?['reason'] as String?; + if (reason == 'eof') { + _state = _state.copyWith(completed: true); + _completedController.add(true); + } else if (reason == 'error') { + _errorController.add(data?['message'] as String? ?? 'Playback error'); + } + break; + + case 'file-loaded': + _state = _state.copyWith(completed: false); + _completedController.add(false); + break; + + case 'playback-restart': + _playbackRestartController.add(null); + break; + + case 'backend-switched': + // Native player switched from ExoPlayer to MPV due to unsupported format + _backendSwitchedController.add(null); + break; + + case 'log-message': + final prefix = data?['prefix'] as String? ?? ''; + final levelStr = data?['level'] as String? ?? 'info'; + final text = data?['text'] as String? ?? ''; + final level = _parseLogLevel(levelStr); + _logController.add(PlayerLog(level: level, prefix: prefix, text: text)); + break; + } + } + + PlayerLogLevel _parseLogLevel(String level) { + return switch (level) { + 'fatal' => PlayerLogLevel.fatal, + 'error' => PlayerLogLevel.error, + 'warn' => PlayerLogLevel.warn, + 'info' => PlayerLogLevel.info, + 'v' || 'verbose' => PlayerLogLevel.verbose, + 'debug' => PlayerLogLevel.debug, + 'trace' => PlayerLogLevel.trace, + _ => PlayerLogLevel.info, + }; + } + + Tracks _parseTrackList(List trackList) { + final audioTracks = []; + final subtitleTracks = []; + + for (final track in trackList) { + if (track is! Map) continue; + + final type = track['type'] as String?; + final id = track['id']?.toString() ?? ''; + + if (type == 'audio') { + audioTracks.add( + AudioTrack( + id: id, + title: track['title'] as String?, + language: track['lang'] as String?, + codec: track['codec'] as String?, + channels: (track['demux-channel-count'] as num?)?.toInt(), + sampleRate: (track['demux-samplerate'] as num?)?.toInt(), + isDefault: track['default'] as bool? ?? false, + ), + ); + } else if (type == 'sub') { + subtitleTracks.add( + SubtitleTrack( + id: id, + title: track['title'] as String?, + language: track['lang'] as String?, + codec: track['codec'] as String?, + isExternal: track['external'] as bool? ?? false, + uri: track['external-filename'] as String?, + ), + ); + } + } + + return Tracks(audio: audioTracks, subtitle: subtitleTracks); + } + + void _updateSelectedAudioTrack(dynamic trackId) { + final id = trackId?.toString(); + AudioTrack? selectedTrack; + + if (id != null && id != 'no') { + selectedTrack = _state.tracks.audio.cast().firstWhere((t) => t?.id == id, orElse: () => null); + } + + _state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack)); + _trackController.add(_state.track); + } + + void _updateSelectedSubtitleTrack(dynamic trackId) { + final id = trackId?.toString(); + SubtitleTrack? selectedTrack; + + if (id == null || id == 'no') { + selectedTrack = SubtitleTrack.off; + } else { + selectedTrack = _state.tracks.subtitle.cast().firstWhere((t) => t?.id == id, orElse: () => null); + } + + _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); + _trackController.add(_state.track); + } + + Future _ensureInitialized() async { + if (_initialized) return; + + try { + final result = await _methodChannel.invokeMethod('initialize'); + _initialized = result == true; + if (!_initialized) { + throw Exception('Failed to initialize ExoPlayer'); + } + } catch (e) { + _errorController.add('Initialization failed: $e'); + rethrow; + } + } + + void _checkDisposed() { + if (_disposed) { + throw StateError('Player has been disposed'); + } + } + + // ============================================ + // Playback Control + // ============================================ + + @override + Future open(Media media, {bool play = true}) async { + _checkDisposed(); + await _ensureInitialized(); + + // Show the video layer + await setVisible(true); + + await _methodChannel.invokeMethod('open', { + 'uri': media.uri, + 'headers': media.headers, + 'startPositionMs': media.start?.inMilliseconds ?? 0, + 'autoPlay': play, + }); + } + + @override + Future play() async { + _checkDisposed(); + await _methodChannel.invokeMethod('play'); + } + + @override + Future pause() async { + _checkDisposed(); + await _methodChannel.invokeMethod('pause'); + } + + @override + Future playOrPause() async { + _checkDisposed(); + if (_state.playing) { + await pause(); + } else { + await play(); + } + } + + @override + Future stop() async { + _checkDisposed(); + await _methodChannel.invokeMethod('stop'); + await setVisible(false); + } + + @override + Future seek(Duration position) async { + _checkDisposed(); + await _methodChannel.invokeMethod('seek', {'positionMs': position.inMilliseconds}); + } + + // ============================================ + // Track Selection + // ============================================ + + @override + Future selectAudioTrack(AudioTrack track) async { + _checkDisposed(); + await _methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id}); + } + + @override + Future selectSubtitleTrack(SubtitleTrack track) async { + _checkDisposed(); + await _methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id}); + } + + @override + Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { + _checkDisposed(); + await _methodChannel.invokeMethod('addSubtitleTrack', { + 'uri': uri, + 'title': title, + 'language': language, + 'select': select, + }); + } + + // ============================================ + // Volume and Rate + // ============================================ + + @override + Future setVolume(double volume) async { + _checkDisposed(); + await _methodChannel.invokeMethod('setVolume', {'volume': volume}); + } + + @override + Future setRate(double rate) async { + _checkDisposed(); + await _methodChannel.invokeMethod('setRate', {'rate': rate}); + } + + @override + Future setAudioDevice(AudioDevice device) async { + // ExoPlayer doesn't support audio device selection on Android + // This is a no-op + } + + // ============================================ + // MPV Properties (Compatibility Layer) + // ============================================ + + @override + Future setProperty(String name, String value) async { + _checkDisposed(); + // ExoPlayer doesn't use MPV properties, but we handle common ones + switch (name) { + case 'pause': + if (value == 'yes') { + await pause(); + } else { + await play(); + } + break; + case 'volume': + await setVolume(double.tryParse(value) ?? 100); + break; + case 'speed': + await setRate(double.tryParse(value) ?? 1.0); + break; + // Other properties are no-ops for ExoPlayer + default: + // No-op for MPV-specific properties + break; + } + } + + @override + Future getProperty(String name) async { + _checkDisposed(); + // Return state-based values for common properties + switch (name) { + case 'pause': + return _state.playing ? 'no' : 'yes'; + case 'volume': + return _state.volume.toString(); + case 'speed': + return _state.rate.toString(); + case 'time-pos': + return (_state.position.inMilliseconds / 1000.0).toString(); + case 'duration': + return (_state.duration.inMilliseconds / 1000.0).toString(); + default: + return null; + } + } + + @override + Future command(List args) async { + _checkDisposed(); + // Handle MPV commands by translating to ExoPlayer equivalents + if (args.isEmpty) return; + + switch (args[0]) { + case 'loadfile': + if (args.length > 1) { + await open(Media(args[1])); + } + break; + case 'seek': + if (args.length > 1) { + final seconds = double.tryParse(args[1]) ?? 0; + final mode = args.length > 2 ? args[2] : 'relative'; + if (mode == 'absolute') { + await seek(Duration(milliseconds: (seconds * 1000).toInt())); + } else { + final newPos = _state.position + Duration(milliseconds: (seconds * 1000).toInt()); + await seek(newPos); + } + } + break; + case 'stop': + await stop(); + break; + case 'sub-add': + if (args.length > 1) { + final select = args.length > 2 && args[2] == 'select'; + await addSubtitleTrack(uri: args[1], select: select); + } + break; + // Other commands are no-ops + } + } + + // ============================================ + // Passthrough (Not supported by ExoPlayer) + // ============================================ + + @override + Future setAudioPassthrough(bool enabled) async { + // ExoPlayer doesn't support direct audio passthrough configuration + // This is handled by the device's audio settings + } + + // ============================================ + // Visibility + // ============================================ + + @override + Future setVisible(bool visible) async { + _checkDisposed(); + + try { + await _methodChannel.invokeMethod('setVisible', {'visible': visible}); + return true; + } catch (e) { + _errorController.add('Failed to set visibility: $e'); + return false; + } + } + + @override + Future updateFrame() async { + // Not needed for ExoPlayer on Android + } + + // ============================================ + // Frame Rate Matching + // ============================================ + + @override + Future setVideoFrameRate(double fps, int durationMs) async { + _checkDisposed(); + if (!_initialized) return; + + await _methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); + } + + @override + Future clearVideoFrameRate() async { + _checkDisposed(); + if (!_initialized) return; + + await _methodChannel.invokeMethod('clearVideoFrameRate'); + } + + // ============================================ + // Audio Focus + // ============================================ + + @override + Future requestAudioFocus() async { + _checkDisposed(); + if (!_initialized) return false; + + final result = await _methodChannel.invokeMethod('requestAudioFocus'); + return result ?? false; + } + + @override + Future abandonAudioFocus() async { + _checkDisposed(); + if (!_initialized) return; + + await _methodChannel.invokeMethod('abandonAudioFocus'); + } + + // ============================================ + // Lifecycle + // ============================================ + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + + await _eventSubscription?.cancel(); + await _methodChannel.invokeMethod('dispose'); + + await _playingController.close(); + await _completedController.close(); + await _bufferingController.close(); + await _positionController.close(); + await _durationController.close(); + await _bufferController.close(); + await _volumeController.close(); + await _rateController.close(); + await _tracksController.close(); + await _trackController.close(); + await _logController.close(); + await _errorController.close(); + await _audioDeviceController.close(); + await _audioDevicesController.close(); + await _playbackRestartController.close(); + await _backendSwitchedController.close(); + } +} diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 60ed84e7..7ae7109f 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -46,6 +46,8 @@ class PlayerNative implements Player { final _audioDeviceController = StreamController.broadcast(); final _audioDevicesController = StreamController>.broadcast(); final _playbackRestartController = StreamController.broadcast(); + // MPV handles all formats, so this stream never emits (only ExoPlayer needs fallback) + final _backendSwitchedController = StreamController.broadcast(); StreamSubscription? _eventSubscription; bool _disposed = false; @@ -68,6 +70,7 @@ class PlayerNative implements Player { audioDevice: _audioDeviceController.stream, audioDevices: _audioDevicesController.stream, playbackRestart: _playbackRestartController.stream, + backendSwitched: _backendSwitchedController.stream, ); _setupEventListener(); @@ -656,5 +659,6 @@ class PlayerNative implements Player { await _audioDeviceController.close(); await _audioDevicesController.close(); await _playbackRestartController.close(); + await _backendSwitchedController.close(); } } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index 05f40571..b17e5327 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -50,6 +50,10 @@ class PlayerStreams { /// Stream that emits when playback restarts (first frame ready after load/seek). final Stream playbackRestart; + /// Stream that emits when the native player backend switches (e.g., ExoPlayer to MPV). + /// Only emitted on Android when ExoPlayer encounters an unsupported format. + final Stream backendSwitched; + const PlayerStreams({ required this.playing, required this.completed, @@ -66,5 +70,6 @@ class PlayerStreams { required this.audioDevice, required this.audioDevices, required this.playbackRestart, + required this.backendSwitched, }); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 0d07d3eb..2a76d7b9 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -65,6 +65,7 @@ class _SettingsScreenState extends State { int _maxVolume = 100; bool _enableDiscordRPC = false; bool _matchContentFrameRate = false; + bool _useExoPlayer = true; // Android only: ExoPlayer vs MPV // Update checking state bool _isCheckingForUpdate = false; @@ -99,6 +100,7 @@ class _SettingsScreenState extends State { _maxVolume = _settingsService.getMaxVolume(); _enableDiscordRPC = _settingsService.getEnableDiscordRPC(); _matchContentFrameRate = _settingsService.getMatchContentFrameRate(); + _useExoPlayer = _settingsService.getUseExoPlayer(); _isLoading = false; }); } @@ -260,6 +262,14 @@ class _SettingsScreenState extends State { style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), + if (Platform.isAndroid) + ListTile( + leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), + title: Text(t.settings.playerBackend), + subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showPlayerBackendDialog(), + ), SwitchListTile( secondary: const AppIcon(Symbols.hardware_rounded, fill: 1), title: Text(t.settings.hardwareDecoding), @@ -301,15 +311,17 @@ class _SettingsScreenState extends State { Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen())); }, ), - ListTile( - leading: const AppIcon(Symbols.tune_rounded, fill: 1), - title: Text(t.mpvConfig.title), - subtitle: Text(t.mpvConfig.description), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen())); - }, - ), + // MPV Config is only available when using MPV player backend + if (!Platform.isAndroid || !_useExoPlayer) + ListTile( + leading: const AppIcon(Symbols.tune_rounded, fill: 1), + title: Text(t.mpvConfig.title), + subtitle: Text(t.mpvConfig.description), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen())); + }, + ), ListTile( leading: const AppIcon(Symbols.replay_10_rounded, fill: 1), title: Text(t.settings.smallSkipDuration), @@ -1059,6 +1071,53 @@ class _SettingsScreenState extends State { ); } + void _showPlayerBackendDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(t.settings.playerBackend), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: AppIcon( + _useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, + fill: 1, + ), + title: Text(t.settings.exoPlayer), + subtitle: Text(t.settings.exoPlayerDescription), + onTap: () async { + setState(() { + _useExoPlayer = true; + }); + await _settingsService.setUseExoPlayer(true); + if (context.mounted) Navigator.pop(context); + }, + ), + ListTile( + leading: AppIcon( + !_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, + fill: 1, + ), + title: Text(t.settings.mpv), + subtitle: Text(t.settings.mpvDescription), + onTap: () async { + setState(() { + _useExoPlayer = false; + }); + await _settingsService.setUseExoPlayer(false); + if (context.mounted) Navigator.pop(context); + }, + ), + ], + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], + ); + }, + ); + } + void _showKeyboardShortcutsDialog() { if (_keyboardService == null) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f3245e85..392fae5d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -92,6 +92,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _trackLoadingSubscription; StreamSubscription? _positionSubscription; StreamSubscription? _playbackRestartSubscription; + StreamSubscription? _backendSwitchedSubscription; bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; @@ -290,9 +291,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin final bufferSizeBytes = bufferSizeMB * 1024 * 1024; final enableHardwareDecoding = settingsService.getEnableHardwareDecoding(); final debugLoggingEnabled = settingsService.getEnableDebugLogging(); + final useExoPlayer = settingsService.getUseExoPlayer(); - // Create player - player = Player(); + // Create player (on Android, uses ExoPlayer by default, MPV as fallback) + player = Player(useExoPlayer: useExoPlayer); await player!.setProperty('sub-ass', 'yes'); // Enable libass await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString()); @@ -402,6 +404,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Listen to MPV errors _errorSubscription = player!.streams.error.listen(_onPlayerError); + // Listen for backend switched event (ExoPlayer -> MPV fallback on Android) + if (Platform.isAndroid && useExoPlayer) { + _backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched()); + } + // Listen to buffering state _bufferingSubscription = player!.streams.buffering.listen((isBuffering) { _isBuffering.value = isBuffering; @@ -1069,6 +1076,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _trackLoadingSubscription?.cancel(); _positionSubscription?.cancel(); _playbackRestartSubscription?.cancel(); + _backendSwitchedSubscription?.cancel(); // Cancel auto-play timer _autoPlayTimer?.cancel(); @@ -1204,6 +1212,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin appLogger.e('[MPV ERROR] $error'); } + /// Handle notification when native player switched from ExoPlayer to MPV + void _onBackendSwitched() { + appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)'); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.messages.switchingToCompatiblePlayer), duration: const Duration(seconds: 2)), + ); + } + } + // OS Media Controls Integration /// Wrapper method to update media controls playback state diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index f4165270..02568e8c 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -64,6 +64,7 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyMatchContentFrameRate = 'match_content_frame_rate'; static const String _keyDefaultPlaybackSpeed = 'default_playback_speed'; static const String _keyAutoPlayNextEpisode = 'auto_play_next_episode'; + static const String _keyUseExoPlayer = 'use_exoplayer'; SettingsService._(); @@ -981,6 +982,16 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyAutoPlayNextEpisode) ?? true; // Default enabled } + // Use ExoPlayer on Android (default: true) + // When false, uses MPV as the player backend + Future setUseExoPlayer(bool enabled) async { + await prefs.setBool(_keyUseExoPlayer, enabled); + } + + bool getUseExoPlayer() { + return prefs.getBool(_keyUseExoPlayer) ?? true; // Default: ExoPlayer + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -1025,6 +1036,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyMatchContentFrameRate), prefs.remove(_keyDefaultPlaybackSpeed), prefs.remove(_keyAutoPlayNextEpisode), + prefs.remove(_keyUseExoPlayer), ]); }