feat(player): add mobile edge adjustments

This commit is contained in:
edde746
2026-06-14 13:40:16 +02:00
parent e7809b3160
commit a861a4110f
13 changed files with 1184 additions and 5 deletions
+1
View File
@@ -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 ->
+118
View File
@@ -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
}
}
+127
View File
@@ -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);
});
}