From e8769e705955b94d9ba7109f4437a0b66c41ec1a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 6 May 2026 02:51:22 +0200 Subject: [PATCH] fix(android): detect tv mode beyond leanback close #980 --- .../kotlin/com/edde746/plezy/MainActivity.kt | 46 +++++++++++++- lib/screens/settings/logs_screen.dart | 6 +- lib/utils/platform_detector.dart | 61 ++++++++++++++++++- test/utils/platform_detector_test.dart | 56 +++++++++++++++++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 test/utils/platform_detector_test.dart diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 79909f5c..c8a3f1ed 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -4,6 +4,7 @@ import android.app.AppOpsManager import android.app.PictureInPictureParams import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri import android.os.Build @@ -37,6 +38,7 @@ class MainActivity : FlutterActivity() { private val PIP_CHANNEL = "com.plezy/pip" private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player" private val THEME_CHANNEL = "com.plezy/theme" + private val DEVICE_CHANNEL = "com.plezy/device" private var watchNextPlugin: WatchNextPlugin? = null // Auto PiP state @@ -44,7 +46,40 @@ class MainActivity : FlutterActivity() { private var autoPipWidth: Int = 16 private var autoPipHeight: Int = 9 - private fun isAndroidTvDevice(): Boolean = packageManager.hasSystemFeature("android.software.leanback") + private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean + + private fun getAndroidTvDetection(): Map { + val pm = packageManager + val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK + val isTelevisionUiMode = uiModeType == Configuration.UI_MODE_TYPE_TELEVISION + + @Suppress("DEPRECATION") + val hasTelevisionFeature = pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) + val hasLeanback = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + val hasFireTvFeature = pm.hasSystemFeature("amazon.hardware.fire_tv") + val hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) + val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH) + + val reasons = mutableListOf() + if (isTelevisionUiMode) reasons.add("ui_mode_television") + if (hasTelevisionFeature) reasons.add("television_feature") + if (hasLeanback) reasons.add("leanback") + if (hasFireTvFeature) reasons.add("fire_tv") + if (!hasTouchscreen) reasons.add("no_touchscreen") + + return mapOf( + "isTv" to reasons.isNotEmpty(), + "reasons" to reasons, + "isTelevisionUiMode" to isTelevisionUiMode, + "hasTelevisionFeature" to hasTelevisionFeature, + "hasLeanback" to hasLeanback, + "hasFireTvFeature" to hasFireTvFeature, + "hasTouchscreen" to hasTouchscreen, + "hasFakeTouch" to hasFakeTouch, + "manufacturer" to Build.MANUFACTURER, + "model" to Build.MODEL + ) + } override fun onCreate(savedInstanceState: Bundle?) { // Apply persisted theme color to the window background before anything @@ -130,7 +165,7 @@ class MainActivity : FlutterActivity() { private fun shouldDisableImpeller(): Boolean { // Android TV devices — weaker GPUs, less Impeller testing - if (packageManager.hasSystemFeature("android.software.leanback")) return true + if (isAndroidTvDevice()) return true // Google Tensor SoC (Mali GPU) — Pixel 6+ // SOC_MODEL may return marketing name ("Tensor G2") or internal ID ("GS201") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -164,6 +199,13 @@ class MainActivity : FlutterActivity() { flutterEngine.plugins.add(MpvPlayerPlugin()) flutterEngine.plugins.add(ExoPlayerPlugin()) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "getTvDetection" -> result.success(getAndroidTvDetection()) + else -> result.notImplemented() + } + } + // External player: open local video files with proper content:// URIs MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index b92be01b..8ddf0e2a 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -52,7 +52,11 @@ class _LogsScreenState extends State with MountedSetStateMixin { 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'); + if (TvDetectionService.isTVSync()) { + final reasons = TvDetectionService.tvDetectionReasonsSync(); + final suffix = reasons.isEmpty ? '' : ' (${reasons.join(', ')})'; + buffer.writeln('TV mode: yes$suffix'); + } } else if (Platform.isIOS) { final info = await deviceInfo.iosInfo; buffer.writeln('iOS ${info.systemVersion}'); diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 783fb8c3..e8132acf 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -3,6 +3,30 @@ import 'dart:math'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const _androidFeatureTelevision = 'android.hardware.type.television'; +const _androidFeatureLeanback = 'android.software.leanback'; +const _androidFeatureFireTv = 'amazon.hardware.fire_tv'; +const _androidFeatureTouchscreen = 'android.hardware.touchscreen'; + +class AndroidTvFeatureDetection { + final bool isTv; + final List reasons; + + const AndroidTvFeatureDetection({required this.isTv, required this.reasons}); +} + +AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable features) { + final featureSet = features.toSet(); + final reasons = []; + if (featureSet.contains(_androidFeatureTelevision)) reasons.add('television_feature'); + if (featureSet.contains(_androidFeatureLeanback)) reasons.add('leanback'); + if (featureSet.contains(_androidFeatureFireTv)) reasons.add('fire_tv'); + if (featureSet.isNotEmpty && !featureSet.contains(_androidFeatureTouchscreen)) reasons.add('no_touchscreen'); + + return AndroidTvFeatureDetection(isTv: reasons.isNotEmpty, reasons: reasons); +} /// Service for detecting if the app is running on Android TV or Apple TV. class TvDetectionService { @@ -12,6 +36,7 @@ class TvDetectionService { bool _isTV = false; bool _isAppleTV = false; bool _initialized = false; + List _detectionReasons = const []; TvDetectionService._(); @@ -26,18 +51,23 @@ class TvDetectionService { } static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); + static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); Future _detect(bool forceTv) async { if (_initialized) return; final deviceInfo = DeviceInfoPlugin(); if (Platform.isAndroid) { - final androidInfo = await deviceInfo.androidInfo; - _detected = androidInfo.systemFeatures.contains('android.software.leanback'); + final nativeDetection = await _getNativeAndroidTvDetection(); + final detection = + nativeDetection ?? detectAndroidTvFromSystemFeatures((await deviceInfo.androidInfo).systemFeatures); + _detected = detection.isTv; + _detectionReasons = detection.reasons; } else if (Platform.isIOS) { if (_tvosBuild) { _isAppleTV = true; _detected = true; + _detectionReasons = const ['tvos_build']; } else { final iosInfo = await deviceInfo.iosInfo; final sysName = iosInfo.systemName.toLowerCase(); @@ -47,6 +77,7 @@ class TvDetectionService { iosInfo.model.toLowerCase().contains('appletv') || iosInfo.utsname.machine.toLowerCase().contains('appletv'); _detected = _isAppleTV; + _detectionReasons = _isAppleTV ? const ['apple_tv'] : const []; } } _forceTv = forceTv; @@ -60,6 +91,30 @@ class TvDetectionService { bool get isTV => _isTV; + List get tvDetectionReasons => _effectiveDetectionReasons; + + List get _effectiveDetectionReasons { + final reasons = [..._detectionReasons]; + if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv'); + return reasons; + } + + Future _getNativeAndroidTvDetection() async { + try { + final result = await _deviceChannel.invokeMapMethod('getTvDetection'); + if (result == null) return null; + final reasonsValue = result['reasons']; + final reasons = reasonsValue is Iterable ? reasonsValue.whereType().toList() : []; + final isTv = result['isTv'] == true; + if (isTv && reasons.isEmpty) reasons.add('native'); + return AndroidTvFeatureDetection(isTv: isTv, reasons: reasons); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + /// Update the user force-TV override and recompute the effective flag. void setForceTv(bool value) { _forceTv = value; @@ -72,6 +127,8 @@ class TvDetectionService { /// Synchronous Apple TV check (returns false if not initialized or not tvOS). static bool isAppleTVSync() => _instance?._isAppleTV ?? false; + static List tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const []; + /// Convenience setter that forwards to the singleton if available. static void setForceTVSync(bool value) => _instance?.setForceTv(value); } diff --git a/test/utils/platform_detector_test.dart b/test/utils/platform_detector_test.dart new file mode 100644 index 00000000..a39a3f08 --- /dev/null +++ b/test/utils/platform_detector_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/platform_detector.dart'; + +void main() { + group('detectAndroidTvFromSystemFeatures', () { + test('detects leanback devices', () { + final detection = detectAndroidTvFromSystemFeatures([ + 'android.software.leanback', + 'android.hardware.touchscreen', + ]); + + expect(detection.isTv, isTrue); + expect(detection.reasons, contains('leanback')); + expect(detection.reasons, isNot(contains('no_touchscreen'))); + }); + + test('detects Fire TV even when touchscreen is present', () { + final detection = detectAndroidTvFromSystemFeatures(['amazon.hardware.fire_tv', 'android.hardware.touchscreen']); + + expect(detection.isTv, isTrue); + expect(detection.reasons, contains('fire_tv')); + expect(detection.reasons, isNot(contains('no_touchscreen'))); + }); + + test('detects devices without real touchscreen capability', () { + final detection = detectAndroidTvFromSystemFeatures(['android.hardware.faketouch']); + + expect(detection.isTv, isTrue); + expect(detection.reasons, contains('no_touchscreen')); + }); + + test('detects television feature', () { + final detection = detectAndroidTvFromSystemFeatures([ + 'android.hardware.type.television', + 'android.hardware.touchscreen', + ]); + + expect(detection.isTv, isTrue); + expect(detection.reasons, contains('television_feature')); + }); + + test('does not classify touchscreen-only devices as TV', () { + final detection = detectAndroidTvFromSystemFeatures(['android.hardware.touchscreen']); + + expect(detection.isTv, isFalse); + expect(detection.reasons, isEmpty); + }); + + test('does not classify empty feature lists as no-touchscreen TVs', () { + final detection = detectAndroidTvFromSystemFeatures(const []); + + expect(detection.isTv, isFalse); + expect(detection.reasons, isEmpty); + }); + }); +}