feat(player): add mobile edge adjustments
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
<!-- PIP and media session foreground service permissions -->
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -24,6 +25,7 @@ import android.view.KeyEvent
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -40,6 +42,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
@@ -78,12 +81,14 @@ class MainActivity : FlutterActivity() {
|
||||
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
|
||||
private val THEME_CHANNEL = "com.plezy/theme"
|
||||
private val DEVICE_CHANNEL = "com.plezy/device"
|
||||
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
||||
private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var nativeTextInputFocused = false
|
||||
private var pendingExternalPlayerResult: MethodChannel.Result? = null
|
||||
private var originalWindowBrightness: Float? = null
|
||||
|
||||
private inline fun logTextInputDiag(message: () -> String) {
|
||||
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
|
||||
@@ -460,6 +465,10 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
handleDeviceAdjustmentCall(call.method, call.arguments, result)
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TEXT_INPUT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setNativeTextInputFocused" -> {
|
||||
@@ -702,6 +711,94 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeviceAdjustmentCall(method: String, arguments: Any?, result: MethodChannel.Result) {
|
||||
try {
|
||||
when (method) {
|
||||
"getBrightness" -> result.success(getScreenBrightnessFraction())
|
||||
"setBrightness" -> {
|
||||
setScreenBrightnessFraction(argumentAsDouble(arguments))
|
||||
result.success(null)
|
||||
}
|
||||
"restoreBrightness" -> {
|
||||
restoreScreenBrightness()
|
||||
result.success(null)
|
||||
}
|
||||
"getMediaVolume" -> result.success(getMediaVolumeFraction())
|
||||
"setMediaVolume" -> {
|
||||
setMediaVolumeFraction(argumentAsDouble(arguments))
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
result.error("INVALID_ARGUMENT", e.message ?: e.javaClass.simpleName, null)
|
||||
} catch (e: Exception) {
|
||||
result.error("DEVICE_ADJUSTMENT_FAILED", e.message ?: e.javaClass.simpleName, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun argumentAsDouble(arguments: Any?): Double {
|
||||
val value = (arguments as? Number)?.toDouble()
|
||||
?: throw IllegalArgumentException("Expected a numeric value")
|
||||
if (value.isNaN() || value.isInfinite()) {
|
||||
throw IllegalArgumentException("Expected a finite numeric value")
|
||||
}
|
||||
return value.coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
private fun getScreenBrightnessFraction(): Double {
|
||||
val windowBrightness = window.attributes.screenBrightness
|
||||
if (windowBrightness >= 0f) return windowBrightness.coerceIn(0f, 1f).toDouble()
|
||||
|
||||
return try {
|
||||
Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS).coerceIn(0, 255) / 255.0
|
||||
} catch (e: Settings.SettingNotFoundException) {
|
||||
0.5
|
||||
}
|
||||
}
|
||||
|
||||
private fun setScreenBrightnessFraction(value: Double) {
|
||||
if (originalWindowBrightness == null) originalWindowBrightness = window.attributes.screenBrightness
|
||||
val attributes = window.attributes
|
||||
attributes.screenBrightness = value.coerceIn(0.0, 1.0).toFloat()
|
||||
window.attributes = attributes
|
||||
}
|
||||
|
||||
private fun restoreScreenBrightness() {
|
||||
val attributes = window.attributes
|
||||
attributes.screenBrightness = originalWindowBrightness ?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
window.attributes = attributes
|
||||
originalWindowBrightness = null
|
||||
}
|
||||
|
||||
private fun getMediaVolumeFraction(): Double {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val minVolume = streamMinVolume(audioManager)
|
||||
if (maxVolume <= minVolume) return 0.0
|
||||
|
||||
val volume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).coerceIn(minVolume, maxVolume)
|
||||
return (volume - minVolume).toDouble() / (maxVolume - minVolume).toDouble()
|
||||
}
|
||||
|
||||
private fun setMediaVolumeFraction(value: Double) {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val minVolume = streamMinVolume(audioManager)
|
||||
val target = (minVolume + value.coerceIn(0.0, 1.0) * (maxVolume - minVolume))
|
||||
.roundToInt()
|
||||
.coerceIn(minVolume, maxVolume)
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
}
|
||||
|
||||
private fun streamMinVolume(audioManager: AudioManager): Int {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
flutterEngine?.let { engine ->
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var deviceAdjustmentChannel: FlutterMethodChannel?
|
||||
private var originalBrightness: CGFloat?
|
||||
private var volumeView: MPVolumeView?
|
||||
private weak var volumeSlider: UISlider?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
@@ -29,5 +35,117 @@ import AVFoundation
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
|
||||
MpvPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeviceAdjustmentChannel") {
|
||||
registerDeviceAdjustmentChannel(messenger: registrar.messenger())
|
||||
}
|
||||
}
|
||||
|
||||
private func registerDeviceAdjustmentChannel(messenger: FlutterBinaryMessenger) {
|
||||
let channel = FlutterMethodChannel(name: "com.plezy/device_adjustment", binaryMessenger: messenger)
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
DispatchQueue.main.async {
|
||||
self?.handleDeviceAdjustmentCall(call, result: result)
|
||||
}
|
||||
}
|
||||
deviceAdjustmentChannel = channel
|
||||
}
|
||||
|
||||
private func handleDeviceAdjustmentCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "getBrightness":
|
||||
result(Double(UIScreen.main.brightness))
|
||||
case "setBrightness":
|
||||
guard let value = normalizedArgument(call.arguments, result: result) else { return }
|
||||
if originalBrightness == nil {
|
||||
originalBrightness = UIScreen.main.brightness
|
||||
}
|
||||
UIScreen.main.brightness = CGFloat(value)
|
||||
result(nil)
|
||||
case "restoreBrightness":
|
||||
if let originalBrightness = originalBrightness {
|
||||
UIScreen.main.brightness = originalBrightness
|
||||
self.originalBrightness = nil
|
||||
}
|
||||
result(nil)
|
||||
case "getMediaVolume":
|
||||
result(Double(AVAudioSession.sharedInstance().outputVolume))
|
||||
case "setMediaVolume":
|
||||
guard let value = normalizedArgument(call.arguments, result: result) else { return }
|
||||
if setMediaVolume(value) {
|
||||
result(nil)
|
||||
} else {
|
||||
result(FlutterError(code: "DEVICE_ADJUSTMENT_UNAVAILABLE", message: "System volume slider unavailable", details: nil))
|
||||
}
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedArgument(_ arguments: Any?, result: @escaping FlutterResult) -> Double? {
|
||||
guard let value = arguments as? NSNumber else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a numeric value", details: nil))
|
||||
return nil
|
||||
}
|
||||
let doubleValue = value.doubleValue
|
||||
guard doubleValue.isFinite else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a finite numeric value", details: nil))
|
||||
return nil
|
||||
}
|
||||
return min(1.0, max(0.0, doubleValue))
|
||||
}
|
||||
|
||||
private func setMediaVolume(_ value: Double) -> Bool {
|
||||
guard let slider = ensureVolumeSlider() else { return false }
|
||||
slider.setValue(Float(value), animated: false)
|
||||
slider.sendActions(for: .valueChanged)
|
||||
slider.sendActions(for: .touchUpInside)
|
||||
return true
|
||||
}
|
||||
|
||||
private func ensureVolumeSlider() -> UISlider? {
|
||||
if let volumeSlider = volumeSlider { return volumeSlider }
|
||||
|
||||
let volumeView = self.volumeView ?? MPVolumeView(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
|
||||
volumeView.showsRouteButton = false
|
||||
volumeView.showsVolumeSlider = true
|
||||
volumeView.alpha = 0.01
|
||||
volumeView.isUserInteractionEnabled = false
|
||||
|
||||
if volumeView.superview == nil {
|
||||
activeWindow?.addSubview(volumeView)
|
||||
}
|
||||
volumeView.layoutIfNeeded()
|
||||
|
||||
self.volumeView = volumeView
|
||||
let slider = findVolumeSlider(in: volumeView)
|
||||
volumeSlider = slider
|
||||
return slider
|
||||
}
|
||||
|
||||
private func findVolumeSlider(in view: UIView) -> UISlider? {
|
||||
if let slider = view as? UISlider { return slider }
|
||||
for subview in view.subviews {
|
||||
if let slider = findVolumeSlider(in: subview) { return slider }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var activeWindow: UIWindow? {
|
||||
if #available(iOS 13.0, *) {
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
if let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) {
|
||||
return keyWindow
|
||||
}
|
||||
}
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene, let window = windowScene.windows.first else { continue }
|
||||
return window
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return UIApplication.shared.windows.first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Mobile-only bridge for screen brightness and OS media volume.
|
||||
///
|
||||
/// Values are normalized to 0.0-1.0. Unsupported platforms return null for
|
||||
/// getters and ignore setters.
|
||||
class DeviceAdjustmentService {
|
||||
DeviceAdjustmentService({MethodChannel? channel}) : _channel = channel ?? const MethodChannel(_channelName);
|
||||
|
||||
static const String _channelName = 'com.plezy/device_adjustment';
|
||||
static final DeviceAdjustmentService instance = DeviceAdjustmentService();
|
||||
|
||||
final MethodChannel _channel;
|
||||
AppLifecycleListener? _lifecycleListener;
|
||||
VoidCallback? onResume;
|
||||
bool _restoreSuppressed = false;
|
||||
bool _brightnessChanged = false;
|
||||
int _brightnessGeneration = 0;
|
||||
Future<void> _brightnessQueue = Future<void>.value();
|
||||
|
||||
Future<double?> getBrightness() => _invokeDouble('getBrightness');
|
||||
|
||||
Future<void> setBrightness(double value) async {
|
||||
_ensureLifecycleListener();
|
||||
if (!value.isFinite) return;
|
||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||
final generation = ++_brightnessGeneration;
|
||||
_brightnessChanged = true;
|
||||
|
||||
return _enqueueBrightnessOperation(() async {
|
||||
if (generation != _brightnessGeneration) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('setBrightness', clamped);
|
||||
} on MissingPluginException {
|
||||
// Unsupported platform: keep gestures harmless.
|
||||
if (generation == _brightnessGeneration) _brightnessChanged = false;
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
if (generation == _brightnessGeneration) _brightnessChanged = false;
|
||||
appLogger.w('Failed to set screen brightness', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> restoreBrightness() async {
|
||||
if (!_brightnessChanged) return;
|
||||
final generation = ++_brightnessGeneration;
|
||||
|
||||
return _enqueueBrightnessOperation(() async {
|
||||
if (generation != _brightnessGeneration || !_brightnessChanged) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('restoreBrightness');
|
||||
if (generation == _brightnessGeneration) _brightnessChanged = false;
|
||||
} on MissingPluginException {
|
||||
if (generation == _brightnessGeneration) _brightnessChanged = false;
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
appLogger.w('Failed to restore screen brightness', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _enqueueBrightnessOperation(Future<void> Function() operation) {
|
||||
final next = _brightnessQueue.catchError((_) {}).then((_) => operation());
|
||||
_brightnessQueue = next.catchError((_) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
Future<double?> getMediaVolume() => _invokeDouble('getMediaVolume');
|
||||
|
||||
Future<void> setMediaVolume(double value) async {
|
||||
final clamped = value.clamp(0.0, 1.0);
|
||||
try {
|
||||
await _channel.invokeMethod<void>('setMediaVolume', clamped);
|
||||
} on MissingPluginException {
|
||||
// Unsupported platform: keep gestures harmless.
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
appLogger.w('Failed to set media volume', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<double?> _invokeDouble(String method) async {
|
||||
try {
|
||||
final value = await _channel.invokeMethod<num?>(method);
|
||||
final normalized = value?.toDouble();
|
||||
return normalized?.clamp(0.0, 1.0).toDouble();
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
appLogger.w('Failed to call $method', error: error, stackTrace: stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureLifecycleListener() {
|
||||
_lifecycleListener ??= AppLifecycleListener(
|
||||
onHide: _restoreBrightnessFromLifecycle,
|
||||
onPause: _restoreBrightnessFromLifecycle,
|
||||
onResume: _handleResume,
|
||||
onShow: _handleResume,
|
||||
onDetach: _restoreBrightnessFromLifecycle,
|
||||
);
|
||||
}
|
||||
|
||||
void setRestoreSuppressed(bool suppressed) {
|
||||
_restoreSuppressed = suppressed;
|
||||
}
|
||||
|
||||
void _restoreBrightnessFromLifecycle() {
|
||||
if (_restoreSuppressed) return;
|
||||
unawaited(restoreBrightness());
|
||||
}
|
||||
|
||||
void _handleResume() {
|
||||
onResume?.call();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_lifecycleListener?.dispose();
|
||||
_lifecycleListener = null;
|
||||
onResume = null;
|
||||
_restoreSuppressed = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show Offset, Size;
|
||||
|
||||
import 'package:flutter/gestures.dart' show kTouchSlop;
|
||||
|
||||
import '../widgets/mobile_skip_zones.dart';
|
||||
|
||||
enum MobileEdgeAdjustmentSide { left, right }
|
||||
|
||||
enum MobileEdgeAdjustmentEventType { none, candidate, activated, update, ended, cancelled }
|
||||
|
||||
class MobileEdgeAdjustmentEvent {
|
||||
const MobileEdgeAdjustmentEvent._(this.type, {this.side, this.deltaFraction = 0.0, this.wasActive = false});
|
||||
|
||||
const MobileEdgeAdjustmentEvent.none() : this._(MobileEdgeAdjustmentEventType.none);
|
||||
|
||||
const MobileEdgeAdjustmentEvent.candidate(MobileEdgeAdjustmentSide side)
|
||||
: this._(MobileEdgeAdjustmentEventType.candidate, side: side);
|
||||
|
||||
const MobileEdgeAdjustmentEvent.activated(MobileEdgeAdjustmentSide side, double deltaFraction)
|
||||
: this._(MobileEdgeAdjustmentEventType.activated, side: side, deltaFraction: deltaFraction);
|
||||
|
||||
const MobileEdgeAdjustmentEvent.update(MobileEdgeAdjustmentSide side, double deltaFraction)
|
||||
: this._(MobileEdgeAdjustmentEventType.update, side: side, deltaFraction: deltaFraction);
|
||||
|
||||
const MobileEdgeAdjustmentEvent.ended(MobileEdgeAdjustmentSide side, double deltaFraction)
|
||||
: this._(MobileEdgeAdjustmentEventType.ended, side: side, deltaFraction: deltaFraction);
|
||||
|
||||
const MobileEdgeAdjustmentEvent.cancelled(MobileEdgeAdjustmentSide? side, {bool wasActive = false})
|
||||
: this._(MobileEdgeAdjustmentEventType.cancelled, side: side, wasActive: wasActive);
|
||||
|
||||
final MobileEdgeAdjustmentEventType type;
|
||||
final MobileEdgeAdjustmentSide? side;
|
||||
final double deltaFraction;
|
||||
final bool wasActive;
|
||||
}
|
||||
|
||||
double mobileEdgeAdjustmentZoneWidth(Size size) {
|
||||
final preferredWidth = math.max(48.0, math.min(size.width * 0.14, 96.0));
|
||||
return math.min(preferredWidth, size.width / 2);
|
||||
}
|
||||
|
||||
MobileEdgeAdjustmentSide? mobileEdgeAdjustmentZoneForPosition({required Offset position, required Size size}) {
|
||||
final dimensions = mobileSkipZoneDimensions(size);
|
||||
final inVerticalRange = position.dy > dimensions.topExclude && position.dy < (size.height - dimensions.bottomExclude);
|
||||
if (!inVerticalRange) return null;
|
||||
|
||||
final edgeWidth = mobileEdgeAdjustmentZoneWidth(size);
|
||||
if (position.dx < edgeWidth) return MobileEdgeAdjustmentSide.left;
|
||||
if (position.dx > size.width - edgeWidth) return MobileEdgeAdjustmentSide.right;
|
||||
return null;
|
||||
}
|
||||
|
||||
class MobileEdgeAdjustmentTracker {
|
||||
MobileEdgeAdjustmentTracker({this.verticalSlop = kTouchSlop, this.verticalDominance = 1.5});
|
||||
|
||||
final double verticalSlop;
|
||||
final double verticalDominance;
|
||||
|
||||
final Set<int> _activePointers = <int>{};
|
||||
int? _trackedPointer;
|
||||
Offset? _startPosition;
|
||||
Size? _size;
|
||||
MobileEdgeAdjustmentSide? _side;
|
||||
bool _active = false;
|
||||
bool _blockedUntilAllPointersUp = false;
|
||||
|
||||
bool get isActive => _active;
|
||||
MobileEdgeAdjustmentSide? get side => _side;
|
||||
|
||||
MobileEdgeAdjustmentEvent pointerDown(int pointer, Offset position, Size size) {
|
||||
_activePointers.add(pointer);
|
||||
if (_blockedUntilAllPointersUp) return const MobileEdgeAdjustmentEvent.none();
|
||||
if (_activePointers.length > 1) return _cancelTracking(clearPointers: false, blockUntilAllPointersUp: true);
|
||||
if (_trackedPointer != null) return const MobileEdgeAdjustmentEvent.none();
|
||||
|
||||
final side = mobileEdgeAdjustmentZoneForPosition(position: position, size: size);
|
||||
if (side == null) return const MobileEdgeAdjustmentEvent.none();
|
||||
|
||||
_trackedPointer = pointer;
|
||||
_startPosition = position;
|
||||
_size = size;
|
||||
_side = side;
|
||||
_active = false;
|
||||
return MobileEdgeAdjustmentEvent.candidate(side);
|
||||
}
|
||||
|
||||
MobileEdgeAdjustmentEvent pointerMove(int pointer, Offset position) {
|
||||
if (pointer != _trackedPointer) return const MobileEdgeAdjustmentEvent.none();
|
||||
final start = _startPosition;
|
||||
final size = _size;
|
||||
final side = _side;
|
||||
if (start == null || size == null || side == null) return const MobileEdgeAdjustmentEvent.none();
|
||||
|
||||
final delta = position - start;
|
||||
final absDx = delta.dx.abs();
|
||||
final absDy = delta.dy.abs();
|
||||
|
||||
if (!_active) {
|
||||
if (absDx >= verticalSlop && absDx > absDy * verticalDominance) {
|
||||
return _cancelTracking(clearPointers: false, blockUntilAllPointersUp: true);
|
||||
}
|
||||
if (absDy < verticalSlop) return const MobileEdgeAdjustmentEvent.none();
|
||||
if (absDy <= absDx * verticalDominance) {
|
||||
return _cancelTracking(clearPointers: false, blockUntilAllPointersUp: true);
|
||||
}
|
||||
_active = true;
|
||||
return MobileEdgeAdjustmentEvent.activated(side, _deltaFraction(position));
|
||||
}
|
||||
|
||||
return MobileEdgeAdjustmentEvent.update(side, _deltaFraction(position));
|
||||
}
|
||||
|
||||
MobileEdgeAdjustmentEvent pointerUp(int pointer, Offset position) {
|
||||
_activePointers.remove(pointer);
|
||||
if (_activePointers.isEmpty) _blockedUntilAllPointersUp = false;
|
||||
if (pointer != _trackedPointer) return const MobileEdgeAdjustmentEvent.none();
|
||||
|
||||
final side = _side;
|
||||
final wasActive = _active;
|
||||
final deltaFraction = wasActive ? _deltaFraction(position) : 0.0;
|
||||
_resetTracking(clearPointers: false);
|
||||
|
||||
if (!wasActive || side == null) return const MobileEdgeAdjustmentEvent.none();
|
||||
return MobileEdgeAdjustmentEvent.ended(side, deltaFraction);
|
||||
}
|
||||
|
||||
MobileEdgeAdjustmentEvent pointerCancel(int pointer) {
|
||||
_activePointers.remove(pointer);
|
||||
if (_activePointers.isEmpty) _blockedUntilAllPointersUp = false;
|
||||
if (pointer != _trackedPointer) return const MobileEdgeAdjustmentEvent.none();
|
||||
return _cancelTracking(clearPointers: false, blockUntilAllPointersUp: _activePointers.isNotEmpty);
|
||||
}
|
||||
|
||||
MobileEdgeAdjustmentEvent cancel() => _cancelTracking(clearPointers: true, blockUntilAllPointersUp: false);
|
||||
|
||||
MobileEdgeAdjustmentEvent _cancelTracking({required bool clearPointers, required bool blockUntilAllPointersUp}) {
|
||||
final side = _side;
|
||||
final hadTracking = _trackedPointer != null;
|
||||
final wasActive = _active;
|
||||
_resetTracking(clearPointers: clearPointers);
|
||||
if (blockUntilAllPointersUp && _activePointers.isNotEmpty) _blockedUntilAllPointersUp = true;
|
||||
if (!hadTracking) return const MobileEdgeAdjustmentEvent.none();
|
||||
return MobileEdgeAdjustmentEvent.cancelled(side, wasActive: wasActive);
|
||||
}
|
||||
|
||||
double _deltaFraction(Offset position) {
|
||||
final start = _startPosition;
|
||||
final size = _size;
|
||||
if (start == null || size == null) return 0.0;
|
||||
|
||||
final dimensions = mobileSkipZoneDimensions(size);
|
||||
final activeHeight = math.max(1.0, size.height - dimensions.topExclude - dimensions.bottomExclude);
|
||||
return (start.dy - position.dy) / activeHeight;
|
||||
}
|
||||
|
||||
void _resetTracking({required bool clearPointers}) {
|
||||
_trackedPointer = null;
|
||||
_startPosition = null;
|
||||
_size = null;
|
||||
_side = null;
|
||||
_active = false;
|
||||
if (clearPointers) {
|
||||
_activePointers.clear();
|
||||
_blockedUntilAllPointersUp = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@ class MobileVideoControls extends StatefulWidget {
|
||||
/// Called when the content strip visibility changes
|
||||
final ValueChanged<bool>? onStripVisibilityChanged;
|
||||
|
||||
/// Returns true when a global touch position belongs to the parent edge-adjustment zone.
|
||||
final bool Function(Offset globalPosition)? isInEdgeAdjustmentZone;
|
||||
|
||||
const MobileVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -115,6 +118,7 @@ class MobileVideoControls extends StatefulWidget {
|
||||
this.onQueueItemSelected,
|
||||
this.chromeController,
|
||||
this.onStripVisibilityChanged,
|
||||
this.isInEdgeAdjustmentZone,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -125,6 +129,7 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
late final AnimationController _stripAnim;
|
||||
bool _stripVisible = false;
|
||||
late bool _lastControlsVisible;
|
||||
bool _stripDragEnabled = true;
|
||||
|
||||
/// Drag distance (in pixels) required to fully reveal the strip.
|
||||
static const _dragExtent = 150.0;
|
||||
@@ -183,12 +188,21 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
}
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
_stripDragEnabled = widget.isInEdgeAdjustmentZone?.call(details.globalPosition) != true;
|
||||
}
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
if (!_stripDragEnabled) return;
|
||||
// Negative primaryDelta = swipe up = reveal strip (increase value)
|
||||
_stripAnim.value -= (details.primaryDelta ?? 0) / _dragExtent;
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
if (!_stripDragEnabled) {
|
||||
_stripDragEnabled = true;
|
||||
return;
|
||||
}
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
// Fast swipe up or past halfway without fast swipe down → show strip
|
||||
if (velocity < -200 || (_stripAnim.value > 0.5 && velocity < 200)) {
|
||||
@@ -219,6 +233,7 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
_buildTopBar(context),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onVerticalDragStart,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
|
||||
@@ -122,18 +122,44 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
void _handleTouchPointerDown(PointerDownEvent event) {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
_twoFingerDoubleTapTracker.pointerDown(event.pointer, event.position);
|
||||
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
|
||||
if (_twoFingerDoubleTapTracker.isChordActive) {
|
||||
_suppressTouchTaps();
|
||||
_cancelEdgeAdjustmentGesture();
|
||||
return;
|
||||
}
|
||||
final hit = _edgeAdjustmentSurfaceHit(event.position);
|
||||
_handleEdgeAdjustmentEvent(
|
||||
_edgeAdjustmentGesturesAllowed && hit != null
|
||||
? _edgeAdjustmentTracker.pointerDown(event.pointer, hit.position, hit.size)
|
||||
: const MobileEdgeAdjustmentEvent.none(),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTouchPointerMove(PointerMoveEvent event) {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
_twoFingerDoubleTapTracker.pointerMove(event.pointer, event.position);
|
||||
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
|
||||
if (_twoFingerDoubleTapTracker.isChordActive) {
|
||||
_suppressTouchTaps();
|
||||
_cancelEdgeAdjustmentGesture();
|
||||
return;
|
||||
}
|
||||
if (!_edgeAdjustmentGesturesAllowed) {
|
||||
_cancelEdgeAdjustmentGesture();
|
||||
return;
|
||||
}
|
||||
final hit = _edgeAdjustmentSurfaceHit(event.position);
|
||||
if (hit == null) {
|
||||
_cancelEdgeAdjustmentGesture();
|
||||
return;
|
||||
}
|
||||
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerMove(event.pointer, hit.position));
|
||||
}
|
||||
|
||||
void _handleTouchPointerUp(PointerUpEvent event) {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
final isResetGesture = _twoFingerDoubleTapTracker.pointerUp(event.pointer, event.position);
|
||||
final hit = _edgeAdjustmentSurfaceHit(event.position);
|
||||
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerUp(event.pointer, hit?.position ?? event.localPosition));
|
||||
if (_isTouchTapSuppressed || isResetGesture) _suppressTouchTaps();
|
||||
if (isResetGesture) widget.onResetVideoZoom?.call();
|
||||
}
|
||||
@@ -141,9 +167,225 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
void _handleTouchPointerCancel(PointerCancelEvent event) {
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
_twoFingerDoubleTapTracker.pointerCancel(event.pointer);
|
||||
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerCancel(event.pointer));
|
||||
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
|
||||
}
|
||||
|
||||
bool get _edgeAdjustmentGesturesAllowed {
|
||||
return PlatformDetector.isMobile(context) &&
|
||||
!PlatformDetector.isTV() &&
|
||||
!_isScreenLocked &&
|
||||
!_pipService.isPipActive.value &&
|
||||
!widget.chromeController.contentStripVisible;
|
||||
}
|
||||
|
||||
({Offset position, Size size})? _edgeAdjustmentSurfaceHit(Offset globalPosition) {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox) return null;
|
||||
return (position: renderObject.globalToLocal(globalPosition), size: renderObject.size);
|
||||
}
|
||||
|
||||
bool _isGlobalPositionInEdgeAdjustmentZone(Offset globalPosition) {
|
||||
final hit = _edgeAdjustmentSurfaceHit(globalPosition);
|
||||
if (hit == null) return false;
|
||||
return mobileEdgeAdjustmentZoneForPosition(position: hit.position, size: hit.size) != null;
|
||||
}
|
||||
|
||||
void _refreshDeviceAdjustmentValues() {
|
||||
unawaited(_readEdgeAdjustmentValue(MobileEdgeAdjustmentSide.left));
|
||||
unawaited(_readEdgeAdjustmentValue(MobileEdgeAdjustmentSide.right));
|
||||
}
|
||||
|
||||
Future<double?> _readEdgeAdjustmentValue(MobileEdgeAdjustmentSide side) {
|
||||
++_edgeAdjustmentBaselineGeneration;
|
||||
_edgeAdjustmentBaselineSide = side;
|
||||
final read = side == MobileEdgeAdjustmentSide.left
|
||||
? _deviceAdjustmentService.getBrightness()
|
||||
: _deviceAdjustmentService.getMediaVolume();
|
||||
final future = read.then((value) {
|
||||
if (mounted) _cacheEdgeAdjustmentValue(side, value);
|
||||
return value;
|
||||
});
|
||||
_edgeAdjustmentBaselineFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
void _cacheEdgeAdjustmentValue(MobileEdgeAdjustmentSide side, double? value) {
|
||||
if (value == null) return;
|
||||
if (side == MobileEdgeAdjustmentSide.left) {
|
||||
_lastKnownBrightness = value;
|
||||
} else {
|
||||
_lastKnownMediaVolume = value;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEdgeAdjustmentEvent(MobileEdgeAdjustmentEvent event) {
|
||||
final side = event.side;
|
||||
switch (event.type) {
|
||||
case MobileEdgeAdjustmentEventType.none:
|
||||
return;
|
||||
case MobileEdgeAdjustmentEventType.candidate:
|
||||
if (side != null) unawaited(_readEdgeAdjustmentValue(side));
|
||||
return;
|
||||
case MobileEdgeAdjustmentEventType.activated:
|
||||
if (side != null) _beginEdgeAdjustment(side, event.deltaFraction);
|
||||
return;
|
||||
case MobileEdgeAdjustmentEventType.update:
|
||||
if (side != null) {
|
||||
if (_pendingEdgeAdjustmentSide == side) {
|
||||
_pendingEdgeAdjustmentDelta = event.deltaFraction;
|
||||
} else if (_edgeAdjustmentWasActive) {
|
||||
_updateEdgeAdjustment(side, event.deltaFraction);
|
||||
}
|
||||
}
|
||||
return;
|
||||
case MobileEdgeAdjustmentEventType.ended:
|
||||
if (_pendingEdgeAdjustmentSide != null) {
|
||||
_clearPendingEdgeAdjustment();
|
||||
_finishEdgeAdjustment(suppressTap: false);
|
||||
} else {
|
||||
if (side != null && _edgeAdjustmentWasActive) {
|
||||
_updateEdgeAdjustment(side, event.deltaFraction, forceWrite: true);
|
||||
}
|
||||
_finishEdgeAdjustment(suppressTap: _edgeAdjustmentWasActive);
|
||||
}
|
||||
return;
|
||||
case MobileEdgeAdjustmentEventType.cancelled:
|
||||
_clearPendingEdgeAdjustment();
|
||||
_finishEdgeAdjustment(suppressTap: event.wasActive || _edgeAdjustmentWasActive);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _beginEdgeAdjustment(MobileEdgeAdjustmentSide side, double deltaFraction) {
|
||||
final startValue = _currentEdgeAdjustmentValue(side);
|
||||
if (startValue != null) {
|
||||
_startEdgeAdjustment(side, deltaFraction, startValue: startValue);
|
||||
return;
|
||||
}
|
||||
|
||||
_suppressTouchTaps();
|
||||
if (_isLongPressing) _handleLongPressCancel();
|
||||
|
||||
final Future<double?>? future;
|
||||
final int generation;
|
||||
if (_edgeAdjustmentBaselineSide == side && _edgeAdjustmentBaselineFuture != null) {
|
||||
future = _edgeAdjustmentBaselineFuture;
|
||||
generation = _edgeAdjustmentBaselineGeneration;
|
||||
} else {
|
||||
future = _readEdgeAdjustmentValue(side);
|
||||
generation = _edgeAdjustmentBaselineGeneration;
|
||||
}
|
||||
_pendingEdgeAdjustmentSide = side;
|
||||
_pendingEdgeAdjustmentDelta = deltaFraction;
|
||||
_pendingEdgeAdjustmentGeneration = generation;
|
||||
unawaited(_resolvePendingEdgeAdjustment(side, generation, future));
|
||||
}
|
||||
|
||||
Future<void> _resolvePendingEdgeAdjustment(
|
||||
MobileEdgeAdjustmentSide side,
|
||||
int generation,
|
||||
Future<double?>? future,
|
||||
) async {
|
||||
final value = await (future ?? _readEdgeAdjustmentValue(side)).timeout(
|
||||
const Duration(milliseconds: 300),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (_pendingEdgeAdjustmentSide != side || _pendingEdgeAdjustmentGeneration != generation) return;
|
||||
|
||||
final latestDelta = _pendingEdgeAdjustmentDelta;
|
||||
_clearPendingEdgeAdjustment();
|
||||
_cacheEdgeAdjustmentValue(side, value);
|
||||
final startValue = _currentEdgeAdjustmentValue(side);
|
||||
if (startValue == null) {
|
||||
_finishEdgeAdjustment(suppressTap: false);
|
||||
return;
|
||||
}
|
||||
_startEdgeAdjustment(side, latestDelta, startValue: startValue);
|
||||
}
|
||||
|
||||
void _clearPendingEdgeAdjustment() {
|
||||
_pendingEdgeAdjustmentSide = null;
|
||||
_pendingEdgeAdjustmentDelta = 0.0;
|
||||
_pendingEdgeAdjustmentGeneration = null;
|
||||
}
|
||||
|
||||
void _startEdgeAdjustment(MobileEdgeAdjustmentSide side, double deltaFraction, {required double startValue}) {
|
||||
_suppressTouchTaps();
|
||||
if (_isLongPressing) _handleLongPressCancel();
|
||||
_edgeAdjustmentIndicatorHideTimer?.cancel();
|
||||
_edgeAdjustmentIndicatorClearTimer?.cancel();
|
||||
_edgeAdjustmentWasActive = true;
|
||||
_edgeAdjustmentStartValue = startValue;
|
||||
_lastEdgeAdjustmentWriteAt = null;
|
||||
_lastEdgeAdjustmentWriteValue = null;
|
||||
widget.chromeController.cancelAutoHide();
|
||||
_updateEdgeAdjustment(side, deltaFraction, forceWrite: true);
|
||||
}
|
||||
|
||||
void _updateEdgeAdjustment(MobileEdgeAdjustmentSide side, double deltaFraction, {bool forceWrite = false}) {
|
||||
final startValue = _edgeAdjustmentStartValue ?? _currentEdgeAdjustmentValue(side);
|
||||
if (startValue == null) return;
|
||||
final value = (startValue + deltaFraction).clamp(0.0, 1.0).toDouble();
|
||||
final indicator = _edgeAdjustmentIndicator.value;
|
||||
if (!indicator.visible || indicator.side != side || indicator.value != value) {
|
||||
_edgeAdjustmentIndicator.value = (visible: true, side: side, value: value);
|
||||
}
|
||||
_writeEdgeAdjustment(side, value, force: forceWrite);
|
||||
}
|
||||
|
||||
double? _currentEdgeAdjustmentValue(MobileEdgeAdjustmentSide side) {
|
||||
return switch (side) {
|
||||
MobileEdgeAdjustmentSide.left => _lastKnownBrightness,
|
||||
MobileEdgeAdjustmentSide.right => _lastKnownMediaVolume,
|
||||
};
|
||||
}
|
||||
|
||||
void _writeEdgeAdjustment(MobileEdgeAdjustmentSide side, double value, {required bool force}) {
|
||||
final now = DateTime.now();
|
||||
final lastWriteAt = _lastEdgeAdjustmentWriteAt;
|
||||
final lastValue = _lastEdgeAdjustmentWriteValue;
|
||||
final valueChanged = lastValue == null || (value - lastValue).abs() >= 0.01;
|
||||
final intervalElapsed = lastWriteAt == null || now.difference(lastWriteAt) >= const Duration(milliseconds: 45);
|
||||
if (!force && (!valueChanged || !intervalElapsed)) return;
|
||||
|
||||
_lastEdgeAdjustmentWriteAt = now;
|
||||
_lastEdgeAdjustmentWriteValue = value;
|
||||
if (side == MobileEdgeAdjustmentSide.left) {
|
||||
_lastKnownBrightness = value;
|
||||
unawaited(_deviceAdjustmentService.setBrightness(value));
|
||||
} else {
|
||||
_lastKnownMediaVolume = value;
|
||||
unawaited(_deviceAdjustmentService.setMediaVolume(value));
|
||||
}
|
||||
}
|
||||
|
||||
void _finishEdgeAdjustment({required bool suppressTap}) {
|
||||
if (suppressTap) _suppressTouchTaps();
|
||||
_edgeAdjustmentWasActive = false;
|
||||
_edgeAdjustmentStartValue = null;
|
||||
_lastEdgeAdjustmentWriteAt = null;
|
||||
_lastEdgeAdjustmentWriteValue = null;
|
||||
_restartHideTimerIfPlaying();
|
||||
_edgeAdjustmentIndicatorHideTimer?.cancel();
|
||||
_edgeAdjustmentIndicatorClearTimer?.cancel();
|
||||
if (_edgeAdjustmentIndicator.value.side == null) return;
|
||||
_edgeAdjustmentIndicatorHideTimer = Timer(const Duration(milliseconds: 700), () {
|
||||
if (!mounted) return;
|
||||
final current = _edgeAdjustmentIndicator.value;
|
||||
_edgeAdjustmentIndicator.value = (visible: false, side: current.side, value: current.value);
|
||||
_edgeAdjustmentIndicatorClearTimer = Timer(const Duration(milliseconds: 220), () {
|
||||
if (!mounted) return;
|
||||
_edgeAdjustmentIndicator.value = (visible: false, side: null, value: _edgeAdjustmentIndicator.value.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEdgeAdjustmentGesture() {
|
||||
_handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.cancel());
|
||||
}
|
||||
|
||||
/// Timing-based double-click detection: avoids `onDoubleTap`'s ~300 ms
|
||||
/// tap-resolution delay and the arena competition it introduces.
|
||||
void _handleOuterTap() {
|
||||
|
||||
@@ -112,6 +112,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
}
|
||||
});
|
||||
if (locking) {
|
||||
_cancelEdgeAdjustmentGesture();
|
||||
widget.chromeController.hide(ignoreHolds: true);
|
||||
_startLockIconHideTimer();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import '../../media/media_version.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/device_adjustment_service.dart';
|
||||
import '../../services/scrub_preview_source.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
@@ -66,8 +67,10 @@ import '../../i18n/strings.g.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import 'models/track_controls_state.dart';
|
||||
import 'widgets/double_tap_feedback.dart';
|
||||
import 'helpers/mobile_edge_adjustment_tracker.dart';
|
||||
import 'helpers/two_finger_double_tap_tracker.dart';
|
||||
import 'widgets/linux_keep_alive.dart';
|
||||
import 'widgets/mobile_edge_adjustment_indicator.dart';
|
||||
import 'widgets/mobile_skip_zones.dart';
|
||||
import 'widgets/skip_marker_button.dart';
|
||||
import 'widgets/track_chapter_controls.dart';
|
||||
@@ -164,6 +167,8 @@ typedef PlaybackSourceChangeCallback =
|
||||
int? newSubtitleStreamId,
|
||||
});
|
||||
|
||||
typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide? side, double value});
|
||||
|
||||
class PlexVideoControls extends StatefulWidget {
|
||||
final Player player;
|
||||
final MediaItem metadata;
|
||||
@@ -377,7 +382,28 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
Timer? _feedbackHideTimer; // Removes the skip pill after its fade-out completes
|
||||
Timer? _singleTapTimer; // Timer for delayed single-tap action (toggle controls)
|
||||
final TwoFingerDoubleTapTracker _twoFingerDoubleTapTracker = TwoFingerDoubleTapTracker();
|
||||
final MobileEdgeAdjustmentTracker _edgeAdjustmentTracker = MobileEdgeAdjustmentTracker();
|
||||
final DeviceAdjustmentService _deviceAdjustmentService = DeviceAdjustmentService.instance;
|
||||
DateTime? _suppressTouchTapUntil;
|
||||
final ValueNotifier<_EdgeAdjustmentIndicatorState> _edgeAdjustmentIndicator = ValueNotifier((
|
||||
visible: false,
|
||||
side: null,
|
||||
value: 0.0,
|
||||
));
|
||||
double? _edgeAdjustmentStartValue;
|
||||
bool _edgeAdjustmentWasActive = false;
|
||||
MobileEdgeAdjustmentSide? _pendingEdgeAdjustmentSide;
|
||||
double _pendingEdgeAdjustmentDelta = 0.0;
|
||||
int? _pendingEdgeAdjustmentGeneration;
|
||||
Future<double?>? _edgeAdjustmentBaselineFuture;
|
||||
MobileEdgeAdjustmentSide? _edgeAdjustmentBaselineSide;
|
||||
int _edgeAdjustmentBaselineGeneration = 0;
|
||||
double? _lastKnownBrightness;
|
||||
double? _lastKnownMediaVolume;
|
||||
DateTime? _lastEdgeAdjustmentWriteAt;
|
||||
double? _lastEdgeAdjustmentWriteValue;
|
||||
Timer? _edgeAdjustmentIndicatorHideTimer;
|
||||
Timer? _edgeAdjustmentIndicatorClearTimer;
|
||||
// Seek throttle
|
||||
late final Throttle _seekThrottle;
|
||||
// Current marker state
|
||||
@@ -422,6 +448,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
// PiP support
|
||||
bool _isPipSupported = false;
|
||||
final PipService _pipService = PipService();
|
||||
AppLifecycleListener? _edgeAdjustmentLifecycleListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -469,6 +496,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_listenToPlayingState();
|
||||
_listenToCompleted();
|
||||
_checkPipSupport();
|
||||
_deviceAdjustmentService.onResume = _refreshDeviceAdjustmentValues;
|
||||
_deviceAdjustmentService.setRestoreSuppressed(_pipService.isPipActive.value);
|
||||
_pipService.isPipActive.addListener(_onEdgeAdjustmentPipChanged);
|
||||
_edgeAdjustmentLifecycleListener = AppLifecycleListener(
|
||||
onResume: _refreshDeviceAdjustmentValues,
|
||||
onShow: _refreshDeviceAdjustmentValues,
|
||||
onHide: _cancelEdgeAdjustmentGesture,
|
||||
onPause: _cancelEdgeAdjustmentGesture,
|
||||
);
|
||||
// Add window listener for tracking fullscreen state (for button icon)
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
if (Platform.isMacOS) {
|
||||
@@ -497,6 +533,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_rateSubscription = widget.player.streams.rate.listen(_onRateChanged);
|
||||
_loadPlaybackExtras();
|
||||
_focusPlayPauseIfKeyboardMode();
|
||||
if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
|
||||
_refreshDeviceAdjustmentValues();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -538,10 +577,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_feedbackTimer?.cancel();
|
||||
_feedbackHideTimer?.cancel();
|
||||
_lockIconTimer?.cancel();
|
||||
_edgeAdjustmentIndicatorHideTimer?.cancel();
|
||||
_edgeAdjustmentIndicatorClearTimer?.cancel();
|
||||
_edgeAdjustmentLifecycleListener?.dispose();
|
||||
_edgeAdjustmentLifecycleListener = null;
|
||||
_autoSkipTimer?.cancel();
|
||||
_skipButtonDismissTimer?.cancel();
|
||||
_singleTapTimer?.cancel();
|
||||
_seekThrottle.cancel();
|
||||
_edgeAdjustmentTracker.cancel();
|
||||
_edgeAdjustmentIndicator.dispose();
|
||||
_pipService.isPipActive.removeListener(_onEdgeAdjustmentPipChanged);
|
||||
_deviceAdjustmentService.onResume = null;
|
||||
_deviceAdjustmentService.setRestoreSuppressed(false);
|
||||
unawaited(_deviceAdjustmentService.restoreBrightness());
|
||||
// A player exit mid-scrub must not leak the hold into the route teardown.
|
||||
widget.chromeController.release(PlayerChromeHold.scrub, notify: false, restartAutoHide: false);
|
||||
_playingSubscription?.cancel();
|
||||
@@ -580,6 +629,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_updateTrafficLightVisibility();
|
||||
}
|
||||
|
||||
void _onEdgeAdjustmentPipChanged() {
|
||||
final isInPip = _pipService.isPipActive.value;
|
||||
_deviceAdjustmentService.setRestoreSuppressed(isInPip);
|
||||
if (isInPip) _cancelEdgeAdjustmentGesture();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
if (mounted) {
|
||||
@@ -778,6 +833,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
widget.chromeController.setContentStripVisible(false);
|
||||
}
|
||||
},
|
||||
isInEdgeAdjustmentZone: _isGlobalPositionInEdgeAdjustmentZone,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -827,6 +883,29 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isMobile)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<_EdgeAdjustmentIndicatorState>(
|
||||
valueListenable: _edgeAdjustmentIndicator,
|
||||
builder: (context, indicator, _) {
|
||||
final side = indicator.side;
|
||||
if (side == null) return const SizedBox.shrink();
|
||||
return AnimatedOpacity(
|
||||
opacity: indicator.visible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: RepaintBoundary(
|
||||
child: MobileEdgeAdjustmentIndicator(
|
||||
key: ValueKey(side),
|
||||
side: side,
|
||||
value: indicator.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip intro/credits button (auto-dismisses after 7s, then only shows with controls)
|
||||
if (shouldShowSkipMarkerButton(
|
||||
hasFirstFrame: _hasRenderedFirstFrame,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
|
||||
import '../helpers/mobile_edge_adjustment_tracker.dart';
|
||||
|
||||
class MobileEdgeAdjustmentIndicator extends StatelessWidget {
|
||||
const MobileEdgeAdjustmentIndicator({super.key, required this.side, required this.value});
|
||||
|
||||
final MobileEdgeAdjustmentSide side;
|
||||
final double value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clampedValue = value.clamp(0.0, 1.0);
|
||||
final isLeft = side == MobileEdgeAdjustmentSide.left;
|
||||
final icon = side == MobileEdgeAdjustmentSide.left ? Symbols.brightness_6_rounded : Symbols.volume_up_rounded;
|
||||
final alignment = isLeft ? Alignment.centerLeft : Alignment.centerRight;
|
||||
final margin = isLeft ? const EdgeInsets.only(left: 20) : const EdgeInsets.only(right: 20);
|
||||
|
||||
return SafeArea(
|
||||
minimum: margin,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: _FilledPillIndicator(icon: icon, value: clampedValue),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilledPillIndicator extends StatelessWidget {
|
||||
const _FilledPillIndicator({required this.icon, required this.value});
|
||||
|
||||
final IconData icon;
|
||||
final double value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: value),
|
||||
duration: const Duration(milliseconds: 90),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, animatedValue, _) {
|
||||
return Container(
|
||||
width: 54,
|
||||
height: 184,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.60),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(27)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: Colors.white.withValues(alpha: 0.14)),
|
||||
Align(
|
||||
alignment: .bottomCenter,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: animatedValue,
|
||||
widthFactor: 1,
|
||||
alignment: .bottomCenter,
|
||||
child: ColoredBox(color: Colors.white.withValues(alpha: 0.74)),
|
||||
),
|
||||
),
|
||||
_IndicatorContent(icon: icon, value: animatedValue, color: Colors.white),
|
||||
ClipRect(
|
||||
clipper: _BottomFractionClipper(animatedValue),
|
||||
child: _IndicatorContent(icon: icon, value: animatedValue, color: Colors.black.withValues(alpha: 0.86)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicatorContent extends StatelessWidget {
|
||||
const _IndicatorContent({required this.icon, required this.value, required this.color});
|
||||
|
||||
final IconData icon;
|
||||
final double value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
AppIcon(icon, fill: 1, color: color, size: 24),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${(value * 100).round()}%',
|
||||
textAlign: .center,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
height: 1,
|
||||
fontWeight: .w700,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomFractionClipper extends CustomClipper<Rect> {
|
||||
const _BottomFractionClipper(this.fraction);
|
||||
|
||||
final double fraction;
|
||||
|
||||
@override
|
||||
Rect getClip(Size size) {
|
||||
final height = size.height * fraction.clamp(0.0, 1.0);
|
||||
return Rect.fromLTWH(0, size.height - height, size.width, height);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_BottomFractionClipper oldClipper) => oldClipper.fraction != fraction;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
bool? mobileSkipZoneForTap({required Offset position, required Size size}) {
|
||||
final dimensions = _mobileSkipZoneDimensions(size);
|
||||
final dimensions = mobileSkipZoneDimensions(size);
|
||||
final inVerticalRange = position.dy > dimensions.topExclude && position.dy < (size.height - dimensions.bottomExclude);
|
||||
if (!inVerticalRange) return null;
|
||||
if (position.dx < dimensions.leftZoneWidth) return false;
|
||||
@@ -9,7 +9,7 @@ bool? mobileSkipZoneForTap({required Offset position, required Size size}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
({double topExclude, double bottomExclude, double leftZoneWidth}) _mobileSkipZoneDimensions(Size size) {
|
||||
({double topExclude, double bottomExclude, double leftZoneWidth}) mobileSkipZoneDimensions(Size size) {
|
||||
return (topExclude: size.height * 0.15, bottomExclude: size.height * 0.15, leftZoneWidth: size.width * 0.35);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class MobileSkipZones extends StatelessWidget {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final dimensions = _mobileSkipZoneDimensions(size);
|
||||
final dimensions = mobileSkipZoneDimensions(size);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/device_adjustment_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('test_device_adjustment');
|
||||
late DeviceAdjustmentService service;
|
||||
late List<MethodCall> calls;
|
||||
|
||||
void setHandler(Future<dynamic> Function(MethodCall call)? handler) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, handler);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
calls = <MethodCall>[];
|
||||
service = DeviceAdjustmentService(channel: channel);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
setHandler(null);
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test('getters clamp native values', () async {
|
||||
setHandler((call) async {
|
||||
calls.add(call);
|
||||
return switch (call.method) {
|
||||
'getBrightness' => 2.0,
|
||||
'getMediaVolume' => -1.0,
|
||||
_ => null,
|
||||
};
|
||||
});
|
||||
|
||||
expect(await service.getBrightness(), 1.0);
|
||||
expect(await service.getMediaVolume(), 0.0);
|
||||
});
|
||||
|
||||
test('setters clamp outgoing values', () async {
|
||||
setHandler((call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
await service.setBrightness(-1);
|
||||
await service.setMediaVolume(2);
|
||||
|
||||
expect(calls.map((call) => call.method), ['setBrightness', 'setMediaVolume']);
|
||||
expect(calls[0].arguments, 0.0);
|
||||
expect(calls[1].arguments, 1.0);
|
||||
});
|
||||
|
||||
test('restoreBrightness no-ops before any brightness write', () async {
|
||||
setHandler((call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
await service.restoreBrightness();
|
||||
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test('restoreBrightness waits behind an in-flight brightness write', () async {
|
||||
final setCompleter = Completer<void>();
|
||||
setHandler((call) async {
|
||||
calls.add(call);
|
||||
if (call.method == 'setBrightness') await setCompleter.future;
|
||||
return null;
|
||||
});
|
||||
|
||||
final setFuture = service.setBrightness(0.8);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final restoreFuture = service.restoreBrightness();
|
||||
|
||||
expect(calls.map((call) => call.method), ['setBrightness']);
|
||||
setCompleter.complete();
|
||||
await Future.wait([setFuture, restoreFuture]);
|
||||
|
||||
expect(calls.map((call) => call.method), ['setBrightness', 'restoreBrightness']);
|
||||
});
|
||||
|
||||
test('missing plugin is harmless', () async {
|
||||
setHandler(null);
|
||||
|
||||
expect(await service.getBrightness(), isNull);
|
||||
await service.setBrightness(0.7);
|
||||
await service.restoreBrightness();
|
||||
expect(await service.getMediaVolume(), isNull);
|
||||
await service.setMediaVolume(0.3);
|
||||
});
|
||||
|
||||
test('platform exceptions are swallowed by the service', () async {
|
||||
setHandler((call) async {
|
||||
throw PlatformException(code: 'TEST_FAILURE');
|
||||
});
|
||||
|
||||
expect(await service.getBrightness(), isNull);
|
||||
await service.setBrightness(0.7);
|
||||
await service.restoreBrightness();
|
||||
expect(await service.getMediaVolume(), isNull);
|
||||
await service.setMediaVolume(0.3);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/widgets/video_controls/helpers/mobile_edge_adjustment_tracker.dart';
|
||||
|
||||
void main() {
|
||||
const size = Size(1000, 600);
|
||||
|
||||
test('classifies narrow left and right edge zones', () {
|
||||
expect(
|
||||
mobileEdgeAdjustmentZoneForPosition(position: const Offset(40, 300), size: size),
|
||||
MobileEdgeAdjustmentSide.left,
|
||||
);
|
||||
expect(
|
||||
mobileEdgeAdjustmentZoneForPosition(position: const Offset(960, 300), size: size),
|
||||
MobileEdgeAdjustmentSide.right,
|
||||
);
|
||||
expect(mobileEdgeAdjustmentZoneForPosition(position: const Offset(200, 300), size: size), isNull);
|
||||
});
|
||||
|
||||
test('respects top and bottom exclusion bands', () {
|
||||
expect(mobileEdgeAdjustmentZoneForPosition(position: const Offset(40, 20), size: size), isNull);
|
||||
expect(mobileEdgeAdjustmentZoneForPosition(position: const Offset(960, 580), size: size), isNull);
|
||||
});
|
||||
|
||||
test('activates only after vertically dominant movement', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
expect(tracker.pointerDown(1, const Offset(40, 300), size).type, MobileEdgeAdjustmentEventType.candidate);
|
||||
|
||||
expect(tracker.pointerMove(1, const Offset(43, 292)).type, MobileEdgeAdjustmentEventType.none);
|
||||
|
||||
final event = tracker.pointerMove(1, const Offset(44, 280));
|
||||
expect(event.type, MobileEdgeAdjustmentEventType.activated);
|
||||
expect(event.side, MobileEdgeAdjustmentSide.left);
|
||||
expect(event.deltaFraction, greaterThan(0));
|
||||
});
|
||||
|
||||
test('cancels horizontal movement from the edge', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
tracker.pointerDown(1, const Offset(40, 300), size);
|
||||
|
||||
final event = tracker.pointerMove(1, const Offset(70, 304));
|
||||
expect(event.type, MobileEdgeAdjustmentEventType.cancelled);
|
||||
expect(event.wasActive, isFalse);
|
||||
expect(tracker.isActive, isFalse);
|
||||
});
|
||||
|
||||
test('upward movement increases and downward movement decreases', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
tracker.pointerDown(1, const Offset(960, 300), size);
|
||||
|
||||
final up = tracker.pointerMove(1, const Offset(958, 250));
|
||||
expect(up.type, MobileEdgeAdjustmentEventType.activated);
|
||||
expect(up.deltaFraction, greaterThan(0));
|
||||
|
||||
final down = tracker.pointerMove(1, const Offset(958, 350));
|
||||
expect(down.type, MobileEdgeAdjustmentEventType.update);
|
||||
expect(down.deltaFraction, lessThan(0));
|
||||
});
|
||||
|
||||
test('second pointer cancels tracking', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
tracker.pointerDown(1, const Offset(40, 300), size);
|
||||
|
||||
final event = tracker.pointerDown(2, const Offset(80, 300), size);
|
||||
expect(event.type, MobileEdgeAdjustmentEventType.cancelled);
|
||||
expect(event.wasActive, isFalse);
|
||||
expect(tracker.isActive, isFalse);
|
||||
});
|
||||
|
||||
test('does not reactivate after multitouch cancel until all pointers lift', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
tracker.pointerDown(1, const Offset(40, 300), size);
|
||||
tracker.pointerDown(2, const Offset(80, 300), size);
|
||||
|
||||
expect(tracker.pointerDown(3, const Offset(40, 300), size).type, MobileEdgeAdjustmentEventType.none);
|
||||
expect(tracker.pointerUp(1, const Offset(40, 300)).type, MobileEdgeAdjustmentEventType.none);
|
||||
expect(tracker.pointerDown(4, const Offset(40, 300), size).type, MobileEdgeAdjustmentEventType.none);
|
||||
expect(tracker.pointerUp(2, const Offset(80, 300)).type, MobileEdgeAdjustmentEventType.none);
|
||||
expect(tracker.pointerUp(3, const Offset(40, 300)).type, MobileEdgeAdjustmentEventType.none);
|
||||
expect(tracker.pointerUp(4, const Offset(40, 300)).type, MobileEdgeAdjustmentEventType.none);
|
||||
|
||||
expect(tracker.pointerDown(5, const Offset(40, 300), size).type, MobileEdgeAdjustmentEventType.candidate);
|
||||
});
|
||||
|
||||
test('activated cancel reports active cancellation', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
tracker.pointerDown(1, const Offset(40, 300), size);
|
||||
expect(tracker.pointerMove(1, const Offset(40, 260)).type, MobileEdgeAdjustmentEventType.activated);
|
||||
|
||||
final event = tracker.cancel();
|
||||
expect(event.type, MobileEdgeAdjustmentEventType.cancelled);
|
||||
expect(event.wasActive, isTrue);
|
||||
});
|
||||
|
||||
test('candidate pointer up without activation is ignored', () {
|
||||
final tracker = MobileEdgeAdjustmentTracker(verticalSlop: 10, verticalDominance: 1.5);
|
||||
tracker.pointerDown(1, const Offset(40, 300), size);
|
||||
|
||||
expect(tracker.pointerUp(1, const Offset(42, 302)).type, MobileEdgeAdjustmentEventType.none);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user