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); + }); +}