331 lines
9.2 KiB
Dart
331 lines
9.2 KiB
Dart
|
|
import 'dart:async';
|
||
|
|
import 'dart:io';
|
||
|
|
import 'dart:ui';
|
||
|
|
|
||
|
|
import 'package:flutter/foundation.dart';
|
||
|
|
import 'package:screen_retriever/screen_retriever.dart';
|
||
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
|
import 'package:window_manager/window_manager.dart';
|
||
|
|
|
||
|
|
import '../../../shared/utils/app_logger.dart';
|
||
|
|
|
||
|
|
|
||
|
|
class PipManager {
|
||
|
|
static const _tag = 'Pip';
|
||
|
|
static const double _aspectRatio = 16 / 9;
|
||
|
|
static const Size _minPipSize = Size(360, 202.5);
|
||
|
|
static const Size _defaultPipSize = Size(480, 270);
|
||
|
|
static const Size _maxPipSize = Size(960, 540);
|
||
|
|
static const Size _unboundedMaximumSize = Size(100000, 100000);
|
||
|
|
static const double _edgeMargin = 24;
|
||
|
|
|
||
|
|
static const _keyPipX = 'smplayer.pip_last_x';
|
||
|
|
static const _keyPipY = 'smplayer.pip_last_y';
|
||
|
|
static const _keyPipWidth = 'smplayer.pip_last_width';
|
||
|
|
static const _keyPipHeight = 'smplayer.pip_last_height';
|
||
|
|
static const _keyPipAlwaysOnTop = 'smplayer.pip_always_on_top';
|
||
|
|
|
||
|
|
PipManager({required this._onStateChanged});
|
||
|
|
|
||
|
|
final VoidCallback _onStateChanged;
|
||
|
|
|
||
|
|
bool _isPip = false;
|
||
|
|
bool _isChanging = false;
|
||
|
|
bool _isDisposed = false;
|
||
|
|
Rect? _windowBoundsBefore;
|
||
|
|
bool _wasMaximized = false;
|
||
|
|
bool _wasResizable = true;
|
||
|
|
bool _wasAlwaysOnTop = false;
|
||
|
|
Future<void>? _disposeRestoreFuture;
|
||
|
|
Rect? _lastPipBounds;
|
||
|
|
|
||
|
|
|
||
|
|
bool _pipAlwaysOnTop = true;
|
||
|
|
|
||
|
|
|
||
|
|
static bool isActive = false;
|
||
|
|
|
||
|
|
bool get isPip => _isPip;
|
||
|
|
|
||
|
|
|
||
|
|
bool get isAlwaysOnTop => _pipAlwaysOnTop;
|
||
|
|
|
||
|
|
Future<void> toggle() async {
|
||
|
|
if (_isDisposed) return;
|
||
|
|
if (_isChanging) return;
|
||
|
|
if (_isPip) {
|
||
|
|
await leave();
|
||
|
|
} else {
|
||
|
|
await enter();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> enter() async {
|
||
|
|
if (_isDisposed) return;
|
||
|
|
if (_isPip || _isChanging) return;
|
||
|
|
_isChanging = true;
|
||
|
|
try {
|
||
|
|
_windowBoundsBefore = await windowManager.getBounds();
|
||
|
|
_wasMaximized = await windowManager.isMaximized();
|
||
|
|
_wasResizable = await windowManager.isResizable();
|
||
|
|
_wasAlwaysOnTop = await windowManager.isAlwaysOnTop();
|
||
|
|
_pipAlwaysOnTop = await _loadPersistedAlwaysOnTop();
|
||
|
|
final display = await _tryPrimaryDisplay();
|
||
|
|
|
||
|
|
if (_wasMaximized) {
|
||
|
|
await windowManager.unmaximize();
|
||
|
|
}
|
||
|
|
|
||
|
|
await windowManager.setMinimumSize(_minPipSize);
|
||
|
|
await windowManager.setMaximumSize(
|
||
|
|
display == null ? _maxPipSize : pipMaximumSizeForDisplay(display),
|
||
|
|
);
|
||
|
|
await windowManager.setAspectRatio(_aspectRatio);
|
||
|
|
await windowManager.setResizable(true);
|
||
|
|
await windowManager.setAlwaysOnTop(_pipAlwaysOnTop);
|
||
|
|
|
||
|
|
final lastBounds = _lastPipBounds ?? await _loadPersistedPipBounds();
|
||
|
|
final pipSize = lastBounds != null
|
||
|
|
? Size(lastBounds.width, lastBounds.height)
|
||
|
|
: _defaultPipSize;
|
||
|
|
final bounds =
|
||
|
|
lastBounds ??
|
||
|
|
(display == null
|
||
|
|
? _fallbackPipBounds(pipSize)
|
||
|
|
: pipBoundsForDisplay(display, pipSize));
|
||
|
|
await windowManager.setBounds(bounds);
|
||
|
|
await windowManager.focus();
|
||
|
|
|
||
|
|
_setPipState(true);
|
||
|
|
if (_isDisposed) {
|
||
|
|
_scheduleDisposeRestore();
|
||
|
|
}
|
||
|
|
} catch (error, stack) {
|
||
|
|
AppLogger.warn(_tag, 'Failed to enter pip', error);
|
||
|
|
AppLogger.debug(_tag, stack.toString());
|
||
|
|
try {
|
||
|
|
await _restoreWindowState(updateState: false);
|
||
|
|
} catch (_) {}
|
||
|
|
} finally {
|
||
|
|
_isChanging = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> leave({bool updateState = true}) async {
|
||
|
|
if (_isDisposed && updateState) return;
|
||
|
|
if (!_isPip) return;
|
||
|
|
if (_isChanging) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
_isChanging = true;
|
||
|
|
try {
|
||
|
|
await _restoreWindowState(updateState: updateState);
|
||
|
|
} finally {
|
||
|
|
_isChanging = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
Future<void> toggleAlwaysOnTop() => setAlwaysOnTop(!_pipAlwaysOnTop);
|
||
|
|
|
||
|
|
Future<void> setAlwaysOnTop(bool value) async {
|
||
|
|
if (_isDisposed) return;
|
||
|
|
if (!_isPip) return;
|
||
|
|
if (_pipAlwaysOnTop == value) return;
|
||
|
|
if (_isChanging) return;
|
||
|
|
_isChanging = true;
|
||
|
|
try {
|
||
|
|
if (_isDisposed || !_isPip) return;
|
||
|
|
await windowManager.setAlwaysOnTop(value);
|
||
|
|
_pipAlwaysOnTop = value;
|
||
|
|
await _persistAlwaysOnTop(value);
|
||
|
|
if (!_isDisposed) {
|
||
|
|
_onStateChanged();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
AppLogger.warn(_tag, 'Failed to set pip always-on-top', error);
|
||
|
|
} finally {
|
||
|
|
_isChanging = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void dispose() {
|
||
|
|
if (_isDisposed) return;
|
||
|
|
_isDisposed = true;
|
||
|
|
if (_isPip) {
|
||
|
|
_scheduleDisposeRestore();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@visibleForTesting
|
||
|
|
Future<void>? get disposeRestoreFuture => _disposeRestoreFuture;
|
||
|
|
|
||
|
|
void _scheduleDisposeRestore() {
|
||
|
|
if (_disposeRestoreFuture != null) return;
|
||
|
|
final restore = _restoreAfterDispose();
|
||
|
|
_disposeRestoreFuture = restore;
|
||
|
|
unawaited(restore);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _restoreAfterDispose() async {
|
||
|
|
while (_isChanging) {
|
||
|
|
await Future<void>.delayed(Duration.zero);
|
||
|
|
}
|
||
|
|
if (!_isPip) return;
|
||
|
|
|
||
|
|
_isChanging = true;
|
||
|
|
try {
|
||
|
|
await _restoreWindowState(updateState: false);
|
||
|
|
} finally {
|
||
|
|
_isChanging = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _restoreWindowState({required bool updateState}) async {
|
||
|
|
try {
|
||
|
|
final bounds = await windowManager.getBounds();
|
||
|
|
_lastPipBounds = bounds;
|
||
|
|
unawaited(_persistPipBounds(bounds));
|
||
|
|
} catch (_) {}
|
||
|
|
try {
|
||
|
|
await windowManager.setAspectRatio(-1);
|
||
|
|
await windowManager.setMaximumSize(_unboundedMaximumSize);
|
||
|
|
|
||
|
|
await windowManager.setMinimumSize(const Size(960, 640));
|
||
|
|
await windowManager.setAlwaysOnTop(_wasAlwaysOnTop);
|
||
|
|
await windowManager.setResizable(_wasResizable);
|
||
|
|
|
||
|
|
if (_wasMaximized) {
|
||
|
|
await windowManager.maximize();
|
||
|
|
} else {
|
||
|
|
final bounds = _windowBoundsBefore;
|
||
|
|
if (bounds != null) {
|
||
|
|
await windowManager.setBounds(bounds);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await windowManager.focus();
|
||
|
|
} catch (error) {
|
||
|
|
AppLogger.warn(_tag, 'Failed to restore window', error);
|
||
|
|
}
|
||
|
|
|
||
|
|
_setPipState(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _setPipState(bool pip) {
|
||
|
|
if (_isPip == pip) return;
|
||
|
|
_isPip = pip;
|
||
|
|
PipManager.isActive = pip;
|
||
|
|
if (!_isDisposed) {
|
||
|
|
_onStateChanged();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<Display?> _tryPrimaryDisplay() async {
|
||
|
|
try {
|
||
|
|
return await screenRetriever.getPrimaryDisplay();
|
||
|
|
} catch (error) {
|
||
|
|
AppLogger.warn(_tag, 'Failed to read primary display', error);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Rect _fallbackPipBounds(Size size) {
|
||
|
|
final fallback =
|
||
|
|
_windowBoundsBefore ?? Rect.fromLTWH(0, 0, size.width, size.height);
|
||
|
|
return Rect.fromLTWH(fallback.left, fallback.top, size.width, size.height);
|
||
|
|
}
|
||
|
|
|
||
|
|
@visibleForTesting
|
||
|
|
static Rect pipBoundsForDisplay(Display display, Size size) {
|
||
|
|
final visibleRect = _visibleRectForDisplay(display);
|
||
|
|
final fitted = _fitSizeToVisibleArea(size, visibleRect.size);
|
||
|
|
var left = visibleRect.right - fitted.width - _edgeMargin;
|
||
|
|
final top = visibleRect.bottom - fitted.height - _edgeMargin;
|
||
|
|
|
||
|
|
if (Platform.isWindows) {
|
||
|
|
const border = 8.0;
|
||
|
|
left -= border;
|
||
|
|
}
|
||
|
|
|
||
|
|
return Rect.fromLTWH(left, top, fitted.width, fitted.height);
|
||
|
|
}
|
||
|
|
|
||
|
|
@visibleForTesting
|
||
|
|
static Size pipMaximumSizeForDisplay(Display display) {
|
||
|
|
final visibleRect = _visibleRectForDisplay(display);
|
||
|
|
final fitted = _fitSizeToVisibleArea(_maxPipSize, visibleRect.size);
|
||
|
|
if (fitted.width < _minPipSize.width ||
|
||
|
|
fitted.height < _minPipSize.height) {
|
||
|
|
return _minPipSize;
|
||
|
|
}
|
||
|
|
return fitted;
|
||
|
|
}
|
||
|
|
|
||
|
|
static Rect _visibleRectForDisplay(Display display) {
|
||
|
|
final visiblePos = display.visiblePosition ?? Offset.zero;
|
||
|
|
final visibleSize = display.visibleSize ?? display.size;
|
||
|
|
return visiblePos & visibleSize;
|
||
|
|
}
|
||
|
|
|
||
|
|
static Size _fitSizeToVisibleArea(Size preferred, Size visibleSize) {
|
||
|
|
final maxWidth = visibleSize.width - _edgeMargin * 2;
|
||
|
|
final maxHeight = visibleSize.height - _edgeMargin * 2;
|
||
|
|
if (maxWidth <= 0 || maxHeight <= 0) return preferred;
|
||
|
|
|
||
|
|
var width = preferred.width;
|
||
|
|
var height = preferred.height;
|
||
|
|
if (width > maxWidth) {
|
||
|
|
width = maxWidth;
|
||
|
|
height = width / _aspectRatio;
|
||
|
|
}
|
||
|
|
if (height > maxHeight) {
|
||
|
|
height = maxHeight;
|
||
|
|
width = height * _aspectRatio;
|
||
|
|
}
|
||
|
|
return Size(width, height);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<Rect?> _loadPersistedPipBounds() async {
|
||
|
|
try {
|
||
|
|
final p = await SharedPreferences.getInstance();
|
||
|
|
final x = p.getDouble(_keyPipX);
|
||
|
|
final y = p.getDouble(_keyPipY);
|
||
|
|
final w = p.getDouble(_keyPipWidth);
|
||
|
|
final h = p.getDouble(_keyPipHeight);
|
||
|
|
if (x == null || y == null || w == null || h == null) return null;
|
||
|
|
if (w <= 0 || h <= 0) return null;
|
||
|
|
return Rect.fromLTWH(x, y, w, h);
|
||
|
|
} catch (_) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _persistPipBounds(Rect bounds) async {
|
||
|
|
try {
|
||
|
|
final p = await SharedPreferences.getInstance();
|
||
|
|
await Future.wait([
|
||
|
|
p.setDouble(_keyPipX, bounds.left),
|
||
|
|
p.setDouble(_keyPipY, bounds.top),
|
||
|
|
p.setDouble(_keyPipWidth, bounds.width),
|
||
|
|
p.setDouble(_keyPipHeight, bounds.height),
|
||
|
|
]);
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<bool> _loadPersistedAlwaysOnTop() async {
|
||
|
|
try {
|
||
|
|
final p = await SharedPreferences.getInstance();
|
||
|
|
return p.getBool(_keyPipAlwaysOnTop) ?? true;
|
||
|
|
} catch (_) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _persistAlwaysOnTop(bool value) async {
|
||
|
|
try {
|
||
|
|
final p = await SharedPreferences.getInstance();
|
||
|
|
await p.setBool(_keyPipAlwaysOnTop, value);
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
}
|