feat(player): remember the swipe brightness level between playbacks

The brightness set with the left-edge swipe was lost the moment playback
ended, so every session started back at the system level. A new
"Remember Brightness Level" toggle under Settings > Playback > Gestures
persists the level a swipe settles on and reapplies it when the next
playback starts (and when the app resumes mid-session). Leaving the
player still restores the pre-playback brightness, so the rest of the
app is unaffected. Default off, preserving current behavior.

close #2178
This commit is contained in:
edde746
2026-08-31 06:43:47 +02:00
parent 4f4a7338e0
commit 44773fd742
30 changed files with 412 additions and 11 deletions
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Səsi tənzimləmək üçün sağ kənarda yuxarı və ya aşağı sürüşdürün",
"gesturePinchToZoom": "Çimdiklə yaxınlaşdır",
"gesturePinchToZoomDescription": "Yaxınlaşdırmaq və ya uzaqlaşdırmaq üçün videoda çimdik hərəkəti edin",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "İdarəetmələr",
"rememberPlayerChanges": "Pleyer dəyişikliklərini yadda saxla",
"rememberPlayerChangesDescription": "Oxutma zamanı edilən dəyişikliklərin harada saxlanacağı və yenidən tətbiq ediləcəyi",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Плъзни нагоре или надолу по десния ръб, за да регулираш силата на звука",
"gesturePinchToZoom": "Стискане за мащабиране",
"gesturePinchToZoomDescription": "Стисни видеото, за да увеличиш или намалиш мащаба",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Контроли",
"rememberPlayerChanges": "Запомняне на промените в плейъра",
"rememberPlayerChangesDescription": "Къде се записва и откъде се прилага отново промяна, направена по време на възпроизвеждане",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Stryg op eller ned i højre kant for at justere lydstyrken",
"gesturePinchToZoom": "Klem for at zoome",
"gesturePinchToZoomDescription": "Klem på videoen for at zoome ind eller ud",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Kontroller",
"rememberPlayerChanges": "Husk afspillerændringer",
"rememberPlayerChangesDescription": "Hvor en ændring under afspilning gemmes og anvendes igen",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Wische am rechten Rand nach oben oder unten, um die Lautstärke anzupassen",
"gesturePinchToZoom": "Zum Zoomen kneifen",
"gesturePinchToZoomDescription": "Kneife auf dem Video, um hinein- oder herauszuzoomen",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Steuerung",
"rememberPlayerChanges": "Playeränderungen merken",
"rememberPlayerChangesDescription": "Wo eine während der Wiedergabe vorgenommene Änderung gespeichert und erneut angewendet wird",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Swipe up or down on the right edge to adjust volume",
"gesturePinchToZoom": "Pinch to Zoom",
"gesturePinchToZoomDescription": "Pinch on the video to zoom in or out",
"rememberBrightnessLevel": "Remember Brightness Level",
"rememberBrightnessLevelDescription": "Start playback at the brightness set by the last swipe",
"controls": "Controls",
"rememberPlayerChanges": "Remember Player Changes",
"rememberPlayerChangesDescription": "Where a change made during playback is saved and reapplied from",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Desliza hacia arriba o abajo en el borde derecho para ajustar el volumen",
"gesturePinchToZoom": "Pellizcar para hacer zoom",
"gesturePinchToZoomDescription": "Pellizca el video para acercar o alejar",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Controles",
"rememberPlayerChanges": "Recordar cambios del reproductor",
"rememberPlayerChangesDescription": "Dónde se guarda y se vuelve a aplicar un cambio realizado durante la reproducción",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Balayez vers le haut ou le bas sur le bord droit pour régler le volume",
"gesturePinchToZoom": "Pincer pour zoomer",
"gesturePinchToZoomDescription": "Pincez la vidéo pour zoomer ou dézoomer",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Commandes",
"rememberPlayerChanges": "Mémoriser les modifications du lecteur",
"rememberPlayerChangesDescription": "Où une modification effectuée pendant la lecture est enregistrée et réappliquée",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Húzd felfelé vagy lefelé a jobb szélén a hangerő beállításához",
"gesturePinchToZoom": "Csípés a nagyításhoz",
"gesturePinchToZoomDescription": "Csípj a videóra a nagyításhoz vagy kicsinyítéshez",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Vezérlők",
"rememberPlayerChanges": "Lejátszó módosításainak megjegyzése",
"rememberPlayerChangesDescription": "A lejátszás közben végzett módosítások mentési és újbóli alkalmazási helye",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Scorri verso l'alto o il basso sul bordo destro per regolare il volume",
"gesturePinchToZoom": "Pizzica per lo zoom",
"gesturePinchToZoomDescription": "Pizzica il video per ingrandire o ridurre",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Controlli",
"rememberPlayerChanges": "Ricorda le modifiche del lettore",
"rememberPlayerChangesDescription": "Dove viene salvata e riapplicata una modifica effettuata durante la riproduzione",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "右端を上下にスワイプして音量を調整します",
"gesturePinchToZoom": "ピンチでズーム",
"gesturePinchToZoomDescription": "動画をピンチしてズームイン・アウト",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "コントロール",
"rememberPlayerChanges": "プレーヤーの変更を記憶",
"rememberPlayerChangesDescription": "再生中に行った変更を保存し、再適用する場所",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Оң жақ шетінде жоғары немесе төмен сырғытып, дыбыс деңгейін реттеу",
"gesturePinchToZoom": "Шымшу арқылы масштабтау",
"gesturePinchToZoomDescription": "Видеода шымшып, жақындату немесе алыстату",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Басқару элементтері",
"rememberPlayerChanges": "Ойнатқыш өзгерістерін есте сақтау",
"rememberPlayerChangesDescription": "Ойнату кезінде жасалған өзгеріс сақталатын және қайта қолданылатын орын",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "오른쪽 가장자리에서 위아래로 스와이프하여 볼륨을 조절합니다",
"gesturePinchToZoom": "핀치 줌",
"gesturePinchToZoomDescription": "비디오에서 핀치하여 확대하거나 축소합니다",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "컨트롤",
"rememberPlayerChanges": "플레이어 변경 사항 기억",
"rememberPlayerChangesDescription": "재생 중 변경한 사항을 저장하고 다시 적용할 위치",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Sveip opp eller ned på høyre kant for å justere volumet",
"gesturePinchToZoom": "Klyp for å zoome",
"gesturePinchToZoomDescription": "Klyp på videoen for å zoome inn eller ut",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Kontroller",
"rememberPlayerChanges": "Husk endringer i spilleren",
"rememberPlayerChangesDescription": "Hvor en endring under avspilling lagres og brukes på nytt",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Veeg op de rechterrand omhoog of omlaag om het volume aan te passen",
"gesturePinchToZoom": "Knijpen om te zoomen",
"gesturePinchToZoomDescription": "Knijp op de video om in of uit te zoomen",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Bediening",
"rememberPlayerChanges": "Spelerwijzigingen onthouden",
"rememberPlayerChangesDescription": "Waar een wijziging tijdens het afspelen wordt opgeslagen en opnieuw toegepast",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Przesuwaj palcem w górę lub w dół na prawej krawędzi, aby regulować głośność",
"gesturePinchToZoom": "Powiększanie ściskaniem",
"gesturePinchToZoomDescription": "Ściśnij wideo palcami, aby powiększyć lub pomniejszyć",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Sterowanie",
"rememberPlayerChanges": "Zapamiętuj zmiany odtwarzacza",
"rememberPlayerChangesDescription": "Miejsce zapisywania i ponownego stosowania zmian dokonanych podczas odtwarzania",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Deslize para cima ou para baixo na borda direita para ajustar o volume",
"gesturePinchToZoom": "Pinça para zoom",
"gesturePinchToZoomDescription": "Pince o vídeo para ampliar ou reduzir",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Controles",
"rememberPlayerChanges": "Lembrar alterações do reprodutor",
"rememberPlayerChangesDescription": "Onde uma alteração feita durante a reprodução é salva e reaplicada",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Проведите вверх или вниз по правому краю, чтобы изменить громкость",
"gesturePinchToZoom": "Щипок для масштабирования",
"gesturePinchToZoomDescription": "Сведите или разведите пальцы на видео, чтобы изменить масштаб",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Элементы управления",
"rememberPlayerChanges": "Запоминать изменения плеера",
"rememberPlayerChangesDescription": "Где сохраняется и откуда повторно применяется изменение, сделанное во время воспроизведения",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 43736 (1988 per locale)
/// Strings: 43738 (1988 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+14 -6
View File
@@ -1203,6 +1203,12 @@ class Translations$settings$en {
/// en: 'Pinch on the video to zoom in or out'
String get gesturePinchToZoomDescription => 'Pinch on the video to zoom in or out';
/// en: 'Remember Brightness Level'
String get rememberBrightnessLevel => 'Remember Brightness Level';
/// en: 'Start playback at the brightness set by the last swipe'
String get rememberBrightnessLevelDescription => 'Start playback at the brightness set by the last swipe';
/// en: 'Controls'
String get controls => 'Controls';
@@ -7288,6 +7294,8 @@ extension on Translations {
'settings.gestureVolumeSwipeDescription' => 'Swipe up or down on the right edge to adjust volume',
'settings.gesturePinchToZoom' => 'Pinch to Zoom',
'settings.gesturePinchToZoomDescription' => 'Pinch on the video to zoom in or out',
'settings.rememberBrightnessLevel' => 'Remember Brightness Level',
'settings.rememberBrightnessLevelDescription' => 'Start playback at the brightness set by the last swipe',
'settings.controls' => 'Controls',
'settings.rememberPlayerChanges' => 'Remember Player Changes',
'settings.rememberPlayerChangesDescription' => 'Where a change made during playback is saved and reapplied from',
@@ -7424,10 +7432,10 @@ extension on Translations {
'fileInfo.streamPath' => 'Stream Path',
'fileInfo.optimizedForStreaming' => 'Optimized for Streaming',
'fileInfo.has64bitOffsets' => '64-bit Offsets',
'fileInfo.protocol' => 'Protocol',
'fileInfo.mediaType' => 'Media Type',
_ => null,
} ?? switch (path) {
'fileInfo.protocol' => 'Protocol',
'fileInfo.mediaType' => 'Media Type',
'fileInfo.sourceKind' => 'Source Kind',
'fileInfo.optimizedVersion' => 'Optimized Version',
'fileInfo.optimizationTarget' => 'Optimization Target',
@@ -7938,10 +7946,10 @@ extension on Translations {
'libraries.filterCategories.genre' => 'Genre',
'libraries.filterCategories.year' => 'Year',
'libraries.filterCategories.contentRating' => 'Content Rating',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Unwatched',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Unwatched',
'libraries.filterCategories.unplayed' => 'Unplayed',
'libraries.filterCategories.favorites' => 'Favorites',
'libraries.sortLabels.title' => 'Title',
@@ -8452,10 +8460,10 @@ extension on Translations {
'downloads.syncRuleAndDownloadsRemoved' => 'Sync rule and associated downloads removed',
'downloads.syncRuleCleanupBusy' => 'Sync rules are currently updating. Try again in a moment.',
'downloads.syncRuleCleanupUnavailable' => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}',
'downloads.activeSyncRules' => 'Sync rules',
_ => null,
} ?? switch (path) {
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}',
'downloads.activeSyncRules' => 'Sync rules',
'downloads.noSyncRules' => 'No sync rules',
'downloads.manageSyncRule' => 'Manage sync',
'downloads.editEpisodeCount' => 'Episode count',
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Svep uppåt eller nedåt i högerkanten för att justera volymen",
"gesturePinchToZoom": "Nyp för att zooma",
"gesturePinchToZoomDescription": "Nyp på videon för att zooma in eller ut",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Kontroller",
"rememberPlayerChanges": "Kom ihåg spelarändringar",
"rememberPlayerChangesDescription": "Var en ändring under uppspelning sparas och tillämpas igen",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Sesi ayarlamak için sağ kenarda yukarı veya aşağı kaydırın",
"gesturePinchToZoom": "Kıstırarak Yakınlaştır",
"gesturePinchToZoomDescription": "Yakınlaştırmak veya uzaklaştırmak için videoyu kıstırın",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Kontroller",
"rememberPlayerChanges": "Oynatıcı değişikliklerini hatırla",
"rememberPlayerChangesDescription": "Oynatma sırasında yapılan değişikliklerin kaydedilip yeniden uygulanacağı yer",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "Ovozni sozlash uchun oʻng chekkada yuqoriga yoki pastga suring",
"gesturePinchToZoom": "Chimchilab masshtablash",
"gesturePinchToZoomDescription": "Videoni yaqinlashtirish yoki uzoqlashtirish uchun chimchilang",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "Boshqaruv elementlari",
"rememberPlayerChanges": "Pleyer oʻzgarishlarini eslab qolish",
"rememberPlayerChangesDescription": "Ijro vaqtida qilingan oʻzgarish qayerda saqlanishi va qayta qoʻllanishi",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "在螢幕右側邊緣上下滑動以調整音量",
"gesturePinchToZoom": "雙指縮放",
"gesturePinchToZoomDescription": "在影片上雙指捏合以放大或縮小",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "控制",
"rememberPlayerChanges": "記住播放器變更",
"rememberPlayerChangesDescription": "播放期間所做的變更要儲存並從何處重新套用",
+2
View File
@@ -388,6 +388,8 @@
"gestureVolumeSwipeDescription": "在屏幕右边缘上下滑动以调节音量",
"gesturePinchToZoom": "双指缩放",
"gesturePinchToZoomDescription": "在视频上双指捏合以放大或缩小",
"rememberBrightnessLevel": "",
"rememberBrightnessLevelDescription": "",
"controls": "控制",
"rememberPlayerChanges": "记住播放器更改",
"rememberPlayerChangesDescription": "播放期间所做的更改保存并重新应用的位置",
@@ -383,6 +383,13 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
title: t.settings.gestureBrightnessSwipe,
subtitle: t.settings.gestureBrightnessSwipeDescription,
),
// Remember the last swiped level between playbacks (#2178).
SettingSwitchTile(
pref: SettingsService.rememberBrightnessLevel,
icon: Symbols.settings_brightness_rounded,
title: t.settings.rememberBrightnessLevel,
subtitle: t.settings.rememberBrightnessLevelDescription,
),
SettingSwitchTile(
pref: SettingsService.gestureVolumeSwipe,
icon: Symbols.volume_up_rounded,
@@ -118,6 +118,18 @@ class DeviceAdjustmentService {
onResume?.call();
}
/// Reset queue and restore state between tests. A queue future chained in
/// one `testWidgets` fake-async zone never completes inside the next test's
/// zone, which would stall every later brightness operation on the shared
/// [instance].
@visibleForTesting
void resetForTesting() {
dispose();
_brightnessChanged = false;
_brightnessGeneration = 0;
_brightnessQueue = Future<void>.value();
}
void dispose() {
_lifecycleListener?.dispose();
_lifecycleListener = null;
+12
View File
@@ -556,6 +556,16 @@ class SettingsService extends BaseSharedPreferencesService {
static const gestureVolumeSwipe = BoolPref('gesture_volume_swipe', defaultValue: true);
static const gesturePinchToZoom = BoolPref('gesture_pinch_to_zoom', defaultValue: true);
/// Remember the brightness level set by the swipe gesture (#2178). When on,
/// playback starts at [rememberedBrightnessLevel] instead of the system
/// level; the player exit still restores the pre-playback brightness.
static const rememberBrightnessLevel = BoolPref('remember_brightness_level');
/// Last brightness the swipe gesture settled on while
/// [rememberBrightnessLevel] was enabled. Negative means "never set";
/// device-local runtime state, so reset-only in the registry.
static const rememberedBrightnessLevel = DoublePref('remembered_brightness_level', defaultValue: -1.0);
/// Deinterlace interlaced video via mpv's `deinterlace=auto` (#2149).
/// mpv-only by design: ExoPlayer has no filter chain.
static const deinterlace = BoolPref('deinterlace');
@@ -1128,6 +1138,7 @@ class SettingsService extends BaseSharedPreferencesService {
gestureBrightnessSwipe,
gestureVolumeSwipe,
gesturePinchToZoom,
rememberBrightnessLevel,
deinterlace,
playerAlwaysOnTop,
specialsOrdering,
@@ -1230,6 +1241,7 @@ class SettingsService extends BaseSharedPreferencesService {
customExternalPlayers,
customRelayUrl,
companionRemoteLastHostAddress,
rememberedBrightnessLevel,
];
/// Settings that "Reset All Settings" actually resets.
@@ -373,6 +373,34 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
unawaited(_readEdgeAdjustmentValue(MobileEdgeAdjustmentSide.right));
}
/// Player entry and resume both funnel here: reapply the remembered swipe
/// brightness (#2178) before re-reading the gesture baselines, so the next
/// swipe starts from the level actually on screen.
void _handleDeviceAdjustmentResume() => unawaited(_applyRememberedBrightnessThenRefresh());
Future<void> _applyRememberedBrightnessThenRefresh() async {
final settings = SettingsService.instance;
if (settings.read(SettingsService.rememberBrightnessLevel) &&
settings.read(SettingsService.gestureBrightnessSwipe)) {
final value = settings.read(SettingsService.rememberedBrightnessLevel);
// Negative means "never set"; the write below only stores 0.0-1.0.
if (value >= 0.0 && value <= 1.0) {
// Await the queued set so the baseline read cannot race past it.
await _deviceAdjustmentService.setBrightness(value);
}
}
if (mounted) _refreshDeviceAdjustmentValues();
}
/// Persist the level a finished brightness swipe settled on (#2178). Runs
/// once per gesture, not per write, to spare SharedPreferences the drag spam.
void _persistRememberedBrightness() {
if (!SettingsService.instance.read(SettingsService.rememberBrightnessLevel)) return;
final value = _lastKnownBrightness;
if (value == null) return;
unawaited(SettingsService.instance.write(SettingsService.rememberedBrightnessLevel, value));
}
Future<double?> _readEdgeAdjustmentValue(MobileEdgeAdjustmentSide side) {
++_edgeAdjustmentBaselineGeneration;
_edgeAdjustmentBaselineSide = side;
@@ -496,6 +524,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
_edgeAdjustmentIndicatorHideTimer?.cancel();
_edgeAdjustmentIndicatorClearTimer?.cancel();
_edgeAdjustmentWasActive = true;
_edgeAdjustmentActiveSide = side;
_edgeAdjustmentStartValue = startValue;
_lastEdgeAdjustmentWriteAt = null;
_lastEdgeAdjustmentWriteValue = null;
@@ -542,7 +571,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
void _finishEdgeAdjustment({required bool suppressTap}) {
if (suppressTap) _suppressTouchTaps();
if (_edgeAdjustmentWasActive && _edgeAdjustmentActiveSide == MobileEdgeAdjustmentSide.left) {
_persistRememberedBrightness();
}
_edgeAdjustmentWasActive = false;
_edgeAdjustmentActiveSide = null;
_edgeAdjustmentStartValue = null;
_lastEdgeAdjustmentWriteAt = null;
_lastEdgeAdjustmentWriteValue = null;
@@ -818,6 +818,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
));
double? _edgeAdjustmentStartValue;
bool _edgeAdjustmentWasActive = false;
MobileEdgeAdjustmentSide? _edgeAdjustmentActiveSide;
MobileEdgeAdjustmentSide? _pendingEdgeAdjustmentSide;
double _pendingEdgeAdjustmentDelta = 0.0;
int? _pendingEdgeAdjustmentGeneration;
@@ -960,12 +961,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_listenToPlayingState();
_listenToCompleted();
_checkPipSupport();
_deviceAdjustmentService.onResume = _refreshDeviceAdjustmentValues;
_deviceAdjustmentService.onResume = _handleDeviceAdjustmentResume;
_deviceAdjustmentService.setRestoreSuppressed(_pipService.isPipActive.value);
_pipService.isPipActive.addListener(_onEdgeAdjustmentPipChanged);
_edgeAdjustmentLifecycleListener = AppLifecycleListener(
onResume: _refreshDeviceAdjustmentValues,
onShow: _refreshDeviceAdjustmentValues,
onResume: _handleDeviceAdjustmentResume,
onShow: _handleDeviceAdjustmentResume,
onHide: _cancelEdgeAdjustmentGesture,
onPause: _cancelEdgeAdjustmentGesture,
);
@@ -1001,7 +1002,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// shortcut.
if (!_focusPlayPauseIfKeyboardMode()) _claimPlayerSurfaceFocus();
if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
_refreshDeviceAdjustmentValues();
_handleDeviceAdjustmentResume();
}
});
}
@@ -0,0 +1,284 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/device_adjustment_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
/// Remember Brightness Level (#2178): with the gestures-menu toggle on, the
/// level a brightness swipe settled on is persisted and reapplied when the
/// next playback starts; the player exit still restores the pre-playback
/// brightness so the rest of the app is untouched.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const adjustmentChannel = MethodChannel('com.plezy/device_adjustment');
late _RecordingPlayer player;
late PlayerChromeController chrome;
late PlayerToastController toast;
late VideoVolumeController volume;
late PlaybackStateProvider playbackState;
late WatchTogetherProvider watchTogether;
late AppDatabase database;
late SettingsService settings;
late List<MethodCall> adjustmentCalls;
late double nativeBrightness;
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
// Phone layout: the touch pointer pipeline and the post-frame device
// adjustment hook are wired only when isMobile(context) && !isTV().
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(false);
adjustmentCalls = <MethodCall>[];
nativeBrightness = 0.5;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(adjustmentChannel, (
call,
) async {
adjustmentCalls.add(call);
switch (call.method) {
case 'getBrightness':
return nativeBrightness;
case 'setBrightness':
nativeBrightness = call.arguments as double;
return null;
case 'getMediaVolume':
return 0.5;
default:
return null;
}
});
database = AppDatabase.forTesting(NativeDatabase.memory());
player = _RecordingPlayer();
chrome = PlayerChromeController();
toast = PlayerToastController();
volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
playbackState = PlaybackStateProvider();
watchTogether = WatchTogetherProvider();
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(adjustmentChannel, null);
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
volume.dispose();
playbackState.dispose();
watchTogether.dispose();
chrome.dispose();
toast.dispose();
await database.close();
});
const surface = Size(800, 600);
Iterable<MethodCall> callsTo(String method) => adjustmentCalls.where((c) => c.method == method);
Future<void> pumpControls(WidgetTester tester) async {
// Reset the shared brightness queue *inside* this test's fake-async zone:
// a queue future minted in setUp (real zone) or a previous test's zone
// schedules its completion on a microtask queue this test never flushes.
DeviceAdjustmentService.instance.resetForTesting();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppDatabase>.value(value: database),
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.android, extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: surface.width,
height: surface.height,
child: PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'remember-brightness'),
toastController: toast,
chromeController: chrome,
canNavigateMediaItems: false,
canControl: true,
),
),
),
),
),
);
// Post-frame device-adjustment hook, then the queued channel round trips.
await tester.pump();
await tester.pump();
// Swipes start from hidden chrome — visible controls cover the left edge
// zone and swallow the pointer before the edge Listener sees it.
chrome.hide();
chrome.markControlsHidden();
await tester.pump();
expect(chrome.controlsVisible, isFalse);
}
Future<void> settle(WidgetTester tester) async {
chrome.cancelAutoHide();
toast.hide();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
}
/// One vertical edge swipe: down, resolve the baseline read, drag, lift.
/// 84px up over the 420px active band (600 minus the 15% exclusion bands)
/// is a +0.2 brightness/volume delta.
Future<void> edgeSwipe(WidgetTester tester, {required double x}) async {
final origin = tester.getRect(find.byType(PlexVideoControls)).topLeft;
final gesture = await tester.startGesture(origin + Offset(x, 400));
await tester.pump(); // Resolve the candidate baseline read deterministically.
await gesture.moveTo(origin + Offset(x, 316));
await tester.pump();
await gesture.up();
await tester.pump();
}
testWidgets('playback opens at the remembered level and exit still restores', (tester) async {
await settings.write(SettingsService.rememberBrightnessLevel, true);
await settings.write(SettingsService.rememberedBrightnessLevel, 0.35);
await pumpControls(tester);
expect(callsTo('setBrightness').map((c) => c.arguments), [closeTo(0.35, 0.001)]);
await settle(tester);
expect(
callsTo('restoreBrightness'),
hasLength(1),
reason: 'leaving the player must not darken the rest of the app',
);
});
testWidgets('an unset remembered level writes nothing at startup', (tester) async {
await settings.write(SettingsService.rememberBrightnessLevel, true);
await pumpControls(tester);
expect(callsTo('setBrightness'), isEmpty);
await settle(tester);
});
testWidgets('with the toggle off a stored level is left alone at startup', (tester) async {
await settings.write(SettingsService.rememberedBrightnessLevel, 0.35);
await pumpControls(tester);
expect(callsTo('setBrightness'), isEmpty);
await settle(tester);
});
testWidgets('a disabled brightness swipe never reapplies a remembered level', (tester) async {
await settings.write(SettingsService.rememberBrightnessLevel, true);
await settings.write(SettingsService.rememberedBrightnessLevel, 0.35);
await settings.write(SettingsService.gestureBrightnessSwipe, false);
await pumpControls(tester);
expect(callsTo('setBrightness'), isEmpty);
await settle(tester);
});
testWidgets('a finished brightness swipe persists the level it settled on', (tester) async {
await settings.write(SettingsService.rememberBrightnessLevel, true);
await pumpControls(tester);
await edgeSwipe(tester, x: 40);
expect(callsTo('setBrightness').map((c) => c.arguments).last, closeTo(0.7, 0.001));
expect(settings.read(SettingsService.rememberedBrightnessLevel), closeTo(0.7, 0.001));
await settle(tester);
});
testWidgets('with the toggle off a brightness swipe persists nothing', (tester) async {
await pumpControls(tester);
await edgeSwipe(tester, x: 40);
expect(callsTo('setBrightness'), isNotEmpty, reason: 'the gesture itself stays live without the toggle');
expect(settings.read(SettingsService.rememberedBrightnessLevel), -1.0);
await settle(tester);
});
testWidgets('a volume swipe never writes the remembered brightness', (tester) async {
await settings.write(SettingsService.rememberBrightnessLevel, true);
await pumpControls(tester);
await edgeSwipe(tester, x: surface.width - 40);
expect(callsTo('setMediaVolume'), isNotEmpty);
expect(settings.read(SettingsService.rememberedBrightnessLevel), -1.0);
await settle(tester);
});
}
/// Minimal [Player] with a fixed playing state against a 45-minute item.
class _RecordingPlayer implements Player {
@override
String get playerType => 'mpv';
@override
PlayerState get state => PlayerState(
playing: true,
position: const Duration(minutes: 10),
duration: const Duration(minutes: 45),
seekable: true,
);
@override
PlayerStreams get streams => PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}