From a861a4110fc1d2214755d050a82e644f4d6c4119 Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Sun, 14 Jun 2026 13:40:16 +0200
Subject: [PATCH] feat(player): add mobile edge adjustments
---
android/app/src/main/AndroidManifest.xml | 1 +
.../kotlin/com/edde746/plezy/MainActivity.kt | 97 +++++++
ios/Runner/AppDelegate.swift | 118 +++++++++
lib/services/device_adjustment_service.dart | 127 +++++++++
.../mobile_edge_adjustment_tracker.dart | 168 ++++++++++++
.../video_controls/mobile_video_controls.dart | 15 ++
.../video_controls/parts/playback_input.dart | 246 +++++++++++++++++-
.../video_controls/parts/visibility.dart | 1 +
.../video_controls/video_controls.dart | 79 ++++++
.../mobile_edge_adjustment_indicator.dart | 123 +++++++++
.../widgets/mobile_skip_zones.dart | 6 +-
.../device_adjustment_service_test.dart | 107 ++++++++
...s_mobile_edge_adjustment_tracker_test.dart | 101 +++++++
13 files changed, 1184 insertions(+), 5 deletions(-)
create mode 100644 lib/services/device_adjustment_service.dart
create mode 100644 lib/widgets/video_controls/helpers/mobile_edge_adjustment_tracker.dart
create mode 100644 lib/widgets/video_controls/widgets/mobile_edge_adjustment_indicator.dart
create mode 100644 test/services/device_adjustment_service_test.dart
create mode 100644 test/widgets/video_controls_mobile_edge_adjustment_tracker_test.dart
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ead0fa52..f4799538 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt
index c33a6625..78e14661 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt
@@ -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 ->
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 0fe76503..7a20c467 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -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
}
}
diff --git a/lib/services/device_adjustment_service.dart b/lib/services/device_adjustment_service.dart
new file mode 100644
index 00000000..cf3a5a59
--- /dev/null
+++ b/lib/services/device_adjustment_service.dart
@@ -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 _brightnessQueue = Future.value();
+
+ Future getBrightness() => _invokeDouble('getBrightness');
+
+ Future 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('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 restoreBrightness() async {
+ if (!_brightnessChanged) return;
+ final generation = ++_brightnessGeneration;
+
+ return _enqueueBrightnessOperation(() async {
+ if (generation != _brightnessGeneration || !_brightnessChanged) return;
+ try {
+ await _channel.invokeMethod('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 _enqueueBrightnessOperation(Future Function() operation) {
+ final next = _brightnessQueue.catchError((_) {}).then((_) => operation());
+ _brightnessQueue = next.catchError((_) {});
+ return next;
+ }
+
+ Future getMediaVolume() => _invokeDouble('getMediaVolume');
+
+ Future setMediaVolume(double value) async {
+ final clamped = value.clamp(0.0, 1.0);
+ try {
+ await _channel.invokeMethod('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 _invokeDouble(String method) async {
+ try {
+ final value = await _channel.invokeMethod(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;
+ }
+}
diff --git a/lib/widgets/video_controls/helpers/mobile_edge_adjustment_tracker.dart b/lib/widgets/video_controls/helpers/mobile_edge_adjustment_tracker.dart
new file mode 100644
index 00000000..cec8e71e
--- /dev/null
+++ b/lib/widgets/video_controls/helpers/mobile_edge_adjustment_tracker.dart
@@ -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 _activePointers = {};
+ 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;
+ }
+ }
+}
diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart
index 95614d2e..0db026de 100644
--- a/lib/widgets/video_controls/mobile_video_controls.dart
+++ b/lib/widgets/video_controls/mobile_video_controls.dart
@@ -82,6 +82,9 @@ class MobileVideoControls extends StatefulWidget {
/// Called when the content strip visibility changes
final ValueChanged? 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 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 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 with SingleTi
_buildTopBar(context),
Expanded(
child: GestureDetector(
+ onVerticalDragStart: _onVerticalDragStart,
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
behavior: HitTestBehavior.translucent,
diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart
index 6a75ace8..7f41a373 100644
--- a/lib/widgets/video_controls/parts/playback_input.dart
+++ b/lib/widgets/video_controls/parts/playback_input.dart
@@ -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 _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? 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 _resolvePendingEdgeAdjustment(
+ MobileEdgeAdjustmentSide side,
+ int generation,
+ Future? 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() {
diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart
index 407ca502..5f528918 100644
--- a/lib/widgets/video_controls/parts/visibility.dart
+++ b/lib/widgets/video_controls/parts/visibility.dart
@@ -112,6 +112,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
}
});
if (locking) {
+ _cancelEdgeAdjustmentGesture();
widget.chromeController.hide(ignoreHolds: true);
_startLockIconHideTimer();
}
diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart
index 1344bcf0..f6468afc 100644
--- a/lib/widgets/video_controls/video_controls.dart
+++ b/lib/widgets/video_controls/video_controls.dart
@@ -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
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? _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
// PiP support
bool _isPipSupported = false;
final PipService _pipService = PipService();
+ AppLifecycleListener? _edgeAdjustmentLifecycleListener;
@override
void initState() {
@@ -469,6 +496,15 @@ class _PlexVideoControlsState extends State
_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
_rateSubscription = widget.player.streams.rate.listen(_onRateChanged);
_loadPlaybackExtras();
_focusPlayPauseIfKeyboardMode();
+ if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
+ _refreshDeviceAdjustmentValues();
+ }
});
}
@@ -538,10 +577,20 @@ class _PlexVideoControlsState extends State
_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
_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
widget.chromeController.setContentStripVisible(false);
}
},
+ isInEdgeAdjustmentZone: _isGlobalPositionInEdgeAdjustmentZone,
);
},
),
@@ -827,6 +883,29 @@ class _PlexVideoControlsState extends State
),
),
),
+ 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,
diff --git a/lib/widgets/video_controls/widgets/mobile_edge_adjustment_indicator.dart b/lib/widgets/video_controls/widgets/mobile_edge_adjustment_indicator.dart
new file mode 100644
index 00000000..68cf2e22
--- /dev/null
+++ b/lib/widgets/video_controls/widgets/mobile_edge_adjustment_indicator.dart
@@ -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(
+ tween: Tween(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 {
+ 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;
+}
diff --git a/lib/widgets/video_controls/widgets/mobile_skip_zones.dart b/lib/widgets/video_controls/widgets/mobile_skip_zones.dart
index 598d50ee..90da62bc 100644
--- a/lib/widgets/video_controls/widgets/mobile_skip_zones.dart
+++ b/lib/widgets/video_controls/widgets/mobile_skip_zones.dart
@@ -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: [
diff --git a/test/services/device_adjustment_service_test.dart b/test/services/device_adjustment_service_test.dart
new file mode 100644
index 00000000..9ff1614f
--- /dev/null
+++ b/test/services/device_adjustment_service_test.dart
@@ -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 calls;
+
+ void setHandler(Future Function(MethodCall call)? handler) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, handler);
+ }
+
+ setUp(() {
+ calls = [];
+ 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();
+ setHandler((call) async {
+ calls.add(call);
+ if (call.method == 'setBrightness') await setCompleter.future;
+ return null;
+ });
+
+ final setFuture = service.setBrightness(0.8);
+ await Future.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);
+ });
+}
diff --git a/test/widgets/video_controls_mobile_edge_adjustment_tracker_test.dart b/test/widgets/video_controls_mobile_edge_adjustment_tracker_test.dart
new file mode 100644
index 00000000..11296813
--- /dev/null
+++ b/test/widgets/video_controls_mobile_edge_adjustment_tracker_test.dart
@@ -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);
+ });
+}