diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 987b1f12..41aafc70 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -100,6 +100,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var frameWatchdogRunnable: Runnable? = null private var frameWatchdogStartTime: Long = 0L var delegate: ExoPlayerDelegate? = null + var debugLoggingEnabled: Boolean = false var isInitialized: Boolean = false private set @@ -128,7 +129,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_GAIN -> { - Log.d(TAG, "Audio focus gained") + emitLog("debug", "audio", "Focus gained") hasAudioFocus = true if (wasPlayingBeforeFocusLoss && isInitialized) { exoPlayer?.play() @@ -136,7 +137,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } AudioManager.AUDIOFOCUS_LOSS -> { - Log.d(TAG, "Audio focus lost permanently") + emitLog("debug", "audio", "Focus lost permanently") hasAudioFocus = false if (isInitialized) { wasPlayingBeforeFocusLoss = exoPlayer?.isPlaying == true @@ -144,7 +145,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - Log.d(TAG, "Audio focus lost transiently") + emitLog("debug", "audio", "Focus lost transiently") hasAudioFocus = false if (isInitialized) { wasPlayingBeforeFocusLoss = exoPlayer?.isPlaying == true @@ -152,12 +153,46 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - Log.d(TAG, "Audio focus lost transiently (can duck), continuing playback") + emitLog("debug", "audio", "Focus lost transiently (can duck)") // Don't pause — let the system handle volume ducking for notifications } } } + private fun emitLog(level: String, prefix: String, message: String) { + when (level) { + "error" -> Log.e(TAG, "[$prefix] $message") + "warn" -> Log.w(TAG, "[$prefix] $message") + "info" -> Log.i(TAG, "[$prefix] $message") + else -> Log.d(TAG, "[$prefix] $message") + } + if (debugLoggingEnabled) { + delegate?.onEvent("log-message", mapOf( + "prefix" to prefix, "level" to level, "text" to message + )) + } + } + + private fun redactUri(uri: String): String { + return try { + val parsed = Uri.parse(uri) + val params = parsed.queryParameterNames + if (params.isEmpty()) return uri + val builder = parsed.buildUpon().clearQuery() + for (name in params) { + val lower = name.lowercase() + if (lower.contains("token") || lower.contains("key") || lower.contains("auth")) { + builder.appendQueryParameter(name, "[REDACTED]") + } else { + builder.appendQueryParameter(name, parsed.getQueryParameter(name)) + } + } + builder.build().toString() + } catch (_: Exception) { + uri + } + } + private fun ensureFlutterOverlayOnTop() { val contentView = activity.findViewById(android.R.id.content) contentView.post { @@ -398,7 +433,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { setBufferDurationsMs(30_000, 60_000, 2_500, 5_000) } }.build() - Log.d(TAG, "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB") + emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=${tunnelingUserEnabled}") exoPlayer = ExoPlayer.Builder(activity) .setTrackSelector(trackSelector!!) @@ -446,16 +481,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private val surfaceCallback = object : android.view.SurfaceHolder.Callback { override fun surfaceCreated(holder: android.view.SurfaceHolder) { - Log.d(TAG, "Surface created") + emitLog("debug", "surface", "Created") ensureFlutterOverlayOnTop() } override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, width: Int, height: Int) { - Log.d(TAG, "Surface changed: ${width}x${height}") + emitLog("debug", "surface", "Changed: ${width}x${height}") } override fun surfaceDestroyed(holder: android.view.SurfaceHolder) { - Log.d(TAG, "Surface destroyed") + emitLog("debug", "surface", "Destroyed") } } @@ -522,7 +557,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { Player.STATE_ENDED -> "ended" else -> "unknown" } - Log.d(TAG, "onPlaybackStateChanged: $stateStr") + emitLog("debug", "state", stateStr) when (state) { Player.STATE_BUFFERING -> { @@ -534,7 +569,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { if (pendingStartPositionMs > 0L) { val currentPos = exoPlayer?.currentPosition ?: 0L if (currentPos < 1000L) { - Log.w(TAG, "Position lost during init (at ${currentPos}ms, expected ${pendingStartPositionMs}ms) — restoring") + emitLog("warn", "state", "Position lost (at ${currentPos}ms, expected ${pendingStartPositionMs}ms) — restoring") exoPlayer?.seekTo(pendingStartPositionMs) } pendingStartPositionMs = 0L @@ -556,12 +591,27 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { override fun onTracksChanged(tracks: Tracks) { Log.d(TAG, "onTracksChanged") + // Log selected video and audio track details + val videoGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected } + if (videoGroup != null) { + val vf = videoGroup.mediaTrackGroup.getFormat(0) + val hdr = vf.colorInfo?.let { ci -> + val transfer = ci.colorTransfer + if (transfer != null && transfer != 0) " HDR(transfer=$transfer)" else "" + } ?: "" + emitLog("info", "tracks", "Video: ${vf.codecs} ${vf.width}x${vf.height}$hdr") + } + if (audioGroup != null) { + val af = audioGroup.mediaTrackGroup.getFormat(0) + emitLog("info", "tracks", "Audio: ${af.codecs} ${af.channelCount}ch ${af.sampleRate}Hz") + } evaluateAudioCodecForTunneling() emitTrackList() } override fun onPlayerError(error: PlaybackException) { - Log.e(TAG, "Player error: ${error.message} (code: ${error.errorCode})", error) + emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${error.cause?.javaClass?.simpleName}") stopFrameWatchdog() if (currentMediaUri != null) { @@ -780,7 +830,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val shouldTunnel = tunnelingUserEnabled && (currentSpeed == 1f) && !tunnelingDisabledForCodec val currentTunneling = selector.parameters.tunnelingEnabled if (shouldTunnel == currentTunneling) return // No change needed - Log.d(TAG, "updateTunnelingState: tunneling $currentTunneling -> $shouldTunnel") + emitLog("info", "tunneling", "tunneling $currentTunneling -> $shouldTunnel (user=$tunnelingUserEnabled, speed=$currentSpeed, codecDisabled=$tunnelingDisabledForCodec)") selector.setParameters( selector.buildUponParameters() .setTunnelingEnabled(shouldTunnel) @@ -804,7 +854,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val newDisabled = !hasHardwareAudioDecoder(mimeType) if (newDisabled != tunnelingDisabledForCodec) { tunnelingDisabledForCodec = newDisabled - Log.i(TAG, "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED" else "enabled"}") + emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}") updateTunnelingState() } } @@ -814,6 +864,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private fun startFrameWatchdog() { stopFrameWatchdog() + emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)") frameWatchdogStartTime = System.currentTimeMillis() frameWatchdogRunnable = object : Runnable { override fun run() { @@ -821,7 +872,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val renderedFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: 0 if (renderedFrames > 0) { - Log.d(TAG, "Frame watchdog: $renderedFrames frames rendered, stopping watchdog") + emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared") stopFrameWatchdog() return } @@ -834,7 +885,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } if (elapsed >= WATCHDOG_TIMEOUT_MS && player.isPlaying && hasVideoTrack) { - Log.w(TAG, "Frame watchdog: 0 frames rendered after ${elapsed}ms with playing video — triggering MPV fallback") + emitLog("warn", "watchdog", "0 frames rendered after ${elapsed}ms — triggering fallback") stopFrameWatchdog() // Trigger fallback via the same delegate path as player errors val uri = currentMediaUri ?: return @@ -894,7 +945,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { playWhenReady = autoPlay } - Log.d(TAG, "Opened live: $uri, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay") + emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay") return } @@ -917,7 +968,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { playWhenReady = autoPlay } - Log.d(TAG, "Opened: $uri, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay") + emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, tunneling=$tunnelingUserEnabled") } fun play() { @@ -1239,7 +1290,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return } - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") + emitLog("info", "framerate", "fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs) @@ -1313,7 +1364,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } if (seamless) { - Log.d(TAG, "Seamless switch available, using CHANGE_FRAME_RATE_ALWAYS") + emitLog("info", "framerate", "Seamless switch available for ${fps}fps") surface.setFrameRate( fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, 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 index 072eae55..fabf797d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -35,6 +35,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private val nameToId = mutableMapOf() private var configuredBufferSizeBytes: Int? = null private var configuredTunnelingEnabled: Boolean = true + private var debugLoggingEnabled: Boolean = false // FlutterPlugin @@ -131,6 +132,12 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } "setSubtitleStyle" -> handleSetSubtitleStyle(call, result) "observeProperty" -> handleObserveProperty(call, result) + "setLogLevel" -> { + val level = call.argument("level") ?: "warn" + debugLoggingEnabled = (level == "v" || level == "debug" || level == "trace") + playerCore?.debugLoggingEnabled = debugLoggingEnabled + result.success(null) + } else -> result.notImplemented() } } @@ -157,6 +164,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, try { playerCore = ExoPlayerCore(currentActivity).apply { delegate = this@ExoPlayerPlugin + this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled } val success = playerCore?.initialize( bufferSizeBytes = bufferSizeBytes, @@ -593,6 +601,12 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, fallbackInProgress = true Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") + if (debugLoggingEnabled) { + onEvent("log-message", mapOf( + "prefix" to "fallback", "level" to "warn", + "text" to "Switching to MPV at ${positionMs}ms: $errorMessage" + )) + } currentActivity.runOnUiThread { try { diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 098a1c19..3d0746b0 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -368,4 +368,15 @@ class PlayerAndroid extends PlayerBase { await methodChannel.invokeMethod('abandonAudioFocus'); } + + // ============================================ + // Log Level + // ============================================ + + @override + Future setLogLevel(String level) async { + checkDisposed(); + if (!initialized) return; + await methodChannel.invokeMethod('setLogLevel', {'level': level}); + } } diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index edbabbe5..eb0a6bf0 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -1,15 +1,19 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../focus/focusable_action_bar.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../utils/app_logger.dart'; +import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/desktop_app_bar.dart'; @@ -22,12 +26,41 @@ class LogsScreen extends StatefulWidget { class _LogsScreenState extends State { List _logs = []; + String _deviceInfo = ''; final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _logs = MemoryLogOutput.getLogs(); + _loadDeviceInfo(); + } + + Future _loadDeviceInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + final deviceInfo = DeviceInfoPlugin(); + final buffer = StringBuffer(); + buffer.writeln('${t.app.title} v${packageInfo.version} (${packageInfo.buildNumber})'); + + if (Platform.isAndroid) { + final info = await deviceInfo.androidInfo; + buffer.writeln('Android ${info.version.release} (API ${info.version.sdkInt})'); + buffer.writeln('${info.manufacturer} ${info.model}'); + if (TvDetectionService.isTVSync()) buffer.writeln('TV mode: yes'); + } else if (Platform.isIOS) { + final info = await deviceInfo.iosInfo; + buffer.writeln('iOS ${info.systemVersion}'); + buffer.writeln(info.utsname.machine); + } else if (Platform.isMacOS) { + final info = await deviceInfo.macOsInfo; + buffer.writeln('macOS ${info.osRelease}'); + buffer.writeln(info.model); + } else if (Platform.isLinux) { + final info = await deviceInfo.linuxInfo; + buffer.writeln('Linux ${info.versionId ?? info.id}'); + } + + if (mounted) setState(() => _deviceInfo = buffer.toString().trimRight()); } @override @@ -60,6 +93,10 @@ class _LogsScreenState extends State { String _formatAllLogs() { final buffer = StringBuffer(); + if (_deviceInfo.isNotEmpty) { + buffer.writeln(_deviceInfo); + buffer.writeln('---'); + } bool isFirst = true; for (final log in _logs.reversed) { if (!isFirst) { @@ -175,6 +212,16 @@ class _LogsScreenState extends State { List _buildLogSpans() { final spans = []; + if (_deviceInfo.isNotEmpty) { + spans.add(TextSpan( + text: '$_deviceInfo\n', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)), + )); + spans.add(TextSpan( + text: '---\n', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)), + )); + } for (var i = 0; i < _logs.length; i++) { if (i > 0) spans.add(const TextSpan(text: '\n')); final log = _logs[i];