@@ -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) {
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user