fix(android): detect tv mode beyond leanback

close #980
This commit is contained in:
edde746
2026-05-06 02:57:55 +02:00
parent 88430ba7b6
commit e8769e7059
4 changed files with 164 additions and 5 deletions
@@ -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<String, Any> {
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<String>()
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) {
+5 -1
View File
@@ -52,7 +52,11 @@ class _LogsScreenState extends State<LogsScreen> 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}');
+59 -2
View File
@@ -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<String> reasons;
const AndroidTvFeatureDetection({required this.isTv, required this.reasons});
}
AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> features) {
final featureSet = features.toSet();
final reasons = <String>[];
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<String> _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<void> _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<String> get tvDetectionReasons => _effectiveDetectionReasons;
List<String> get _effectiveDetectionReasons {
final reasons = <String>[..._detectionReasons];
if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv');
return reasons;
}
Future<AndroidTvFeatureDetection?> _getNativeAndroidTvDetection() async {
try {
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getTvDetection');
if (result == null) return null;
final reasonsValue = result['reasons'];
final reasons = reasonsValue is Iterable ? reasonsValue.whereType<String>().toList() : <String>[];
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<String> tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const [];
/// Convenience setter that forwards to the singleton if available.
static void setForceTVSync(bool value) => _instance?.setForceTv(value);
}
+56
View File
@@ -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);
});
});
}