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? _disposeRestoreFuture; Rect? _lastPipBounds; bool _pipAlwaysOnTop = true; static bool isActive = false; bool get isPip => _isPip; bool get isAlwaysOnTop => _pipAlwaysOnTop; Future toggle() async { if (_isDisposed) return; if (_isChanging) return; if (_isPip) { await leave(); } else { await enter(); } } Future 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 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 toggleAlwaysOnTop() => setAlwaysOnTop(!_pipAlwaysOnTop); Future 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? get disposeRestoreFuture => _disposeRestoreFuture; void _scheduleDisposeRestore() { if (_disposeRestoreFuture != null) return; final restore = _restoreAfterDispose(); _disposeRestoreFuture = restore; unawaited(restore); } Future _restoreAfterDispose() async { while (_isChanging) { await Future.delayed(Duration.zero); } if (!_isPip) return; _isChanging = true; try { await _restoreWindowState(updateState: false); } finally { _isChanging = false; } } Future _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 _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 _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 _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 _loadPersistedAlwaysOnTop() async { try { final p = await SharedPreferences.getInstance(); return p.getBool(_keyPipAlwaysOnTop) ?? true; } catch (_) { return true; } } Future _persistAlwaysOnTop(bool value) async { try { final p = await SharedPreferences.getInstance(); await p.setBool(_keyPipAlwaysOnTop, value); } catch (_) {} } }