Skip to content

Commit 4d49891

Browse files
committed
feat(pip): enhance Picture-in-Picture functionality with improved state management and window styling
1 parent 79c5f66 commit 4d49891

4 files changed

Lines changed: 38 additions & 62 deletions

File tree

lib/features/player/application/playback_controller.dart

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ class PlaybackController extends Notifier<PlaybackState> {
167167
int _playbackGeneration = 0;
168168
final Set<String> _autoFallbackTriedServers = <String>{};
169169
int _seekPreviewGeneration = 0;
170+
// Incremented by dismissUndoSkip(); skipTo() checks this before publishing
171+
// lastSkippedFrom so that a dismiss racing an in-flight seek wins.
172+
int _undoSkipGeneration = 0;
170173
int _manualSeekEpoch = 0;
171174
Duration _resumeGuardPosition = Duration.zero;
172175
DateTime? _resumeGuardUntil;
@@ -1721,6 +1724,7 @@ class PlaybackController extends Notifier<PlaybackState> {
17211724
target,
17221725
engine.state.value.duration,
17231726
);
1727+
final int gen = _undoSkipGeneration;
17241728
_manualSeekEpoch++;
17251729
_clearInteractiveSeek();
17261730
_setSeekPreview(engine, clampedTarget);
@@ -1732,12 +1736,16 @@ class PlaybackController extends Notifier<PlaybackState> {
17321736
if (state.engine == engine) {
17331737
_beginSeekSettle(engine, clampedTarget);
17341738
}
1735-
state = state.copyWith(lastSkippedFrom: from);
1736-
_undoTimer?.cancel();
1737-
_undoTimer = Timer(
1738-
const Duration(seconds: 5),
1739-
() => state = state.copyWith(clearLastSkippedFrom: true),
1740-
);
1739+
// Only publish the undo position if dismissUndoSkip() wasn't called while
1740+
// the seek was in flight (generation mismatch means the dismiss won).
1741+
if (gen == _undoSkipGeneration) {
1742+
state = state.copyWith(lastSkippedFrom: from);
1743+
_undoTimer?.cancel();
1744+
_undoTimer = Timer(
1745+
const Duration(seconds: 5),
1746+
() => state = state.copyWith(clearLastSkippedFrom: true),
1747+
);
1748+
}
17411749
}
17421750

17431751
Future<void> undoSkip() async {
@@ -1811,6 +1819,12 @@ class PlaybackController extends Notifier<PlaybackState> {
18111819

18121820
void dismissAutoNext() => state = state.copyWith(autoNextVisible: false);
18131821

1822+
void dismissUndoSkip() {
1823+
_undoSkipGeneration++;
1824+
_undoTimer?.cancel();
1825+
state = state.copyWith(clearLastSkippedFrom: true);
1826+
}
1827+
18141828
Future<void> _saveProgress(
18151829
MediaPlaybackItem item,
18161830
PlayerEngine engine,

lib/features/player/presentation/player_page.dart

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class _PlayerPageState extends ConsumerState<PlayerPage> {
102102
setState(() => _inPipMode = inPip);
103103
if (inPip) {
104104
_hideControls();
105+
} else {
106+
// Auto-skips that fired while the UI was hidden are no longer relevant.
107+
ref.read(playbackControllerProvider.notifier).dismissUndoSkip();
105108
}
106109
});
107110
// Initialise from current PiP state in case we're opened while already in PiP.
@@ -777,39 +780,8 @@ class _PlayerPageState extends ConsumerState<PlayerPage> {
777780
Positioned.fill(
778781
child: _NativePipOverlay(onCancel: () {}),
779782
),
780-
// Desktop PiP overlay: drag strip (Windows frameless) +
781-
// expand button to exit PiP mode.
782-
if (_inPipMode && !_isMobile) ...<Widget>[
783-
if (defaultTargetPlatform == TargetPlatform.windows)
784-
Positioned(
785-
top: 0,
786-
left: 0,
787-
right: 0,
788-
height: 28,
789-
child: IgnorePointer(
790-
child: Container(
791-
alignment: Alignment.center,
792-
decoration: BoxDecoration(
793-
gradient: LinearGradient(
794-
begin: Alignment.topCenter,
795-
end: Alignment.bottomCenter,
796-
colors: <Color>[
797-
Colors.black54,
798-
Colors.transparent,
799-
],
800-
),
801-
),
802-
child: Container(
803-
width: 32,
804-
height: 4,
805-
decoration: BoxDecoration(
806-
color: Colors.white38,
807-
borderRadius: BorderRadius.circular(2),
808-
),
809-
),
810-
),
811-
),
812-
),
783+
// Desktop PiP overlay: expand button to exit PiP mode.
784+
if (_inPipMode && !_isMobile)
813785
Positioned(
814786
right: 6,
815787
bottom: 6,
@@ -827,7 +799,6 @@ class _PlayerPageState extends ConsumerState<PlayerPage> {
827799
),
828800
),
829801
),
830-
],
831802
],
832803
),
833804
),

windows/runner/flutter_window.cpp

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -133,15 +133,21 @@ void FlutterWindow::SetupPipChannel() {
133133
}
134134

135135
if (!is_pip_) {
136-
pip_saved_style_ = GetWindowLongPtr(hwnd, GWL_STYLE);
137136
pip_saved_placement_.length = sizeof(WINDOWPLACEMENT);
138137
GetWindowPlacement(hwnd, &pip_saved_placement_);
139138
}
140139

141-
// Size the PiP window to ~380 px wide, height from aspect ratio.
142-
const int pip_w = 380;
143-
const int pip_h =
144-
static_cast<int>(pip_w / aspect_ratio + 0.5);
140+
// Keep the existing window style (title bar stays) to avoid
141+
// triggering a D3D surface rebuild that corrupts the FVP renderer.
142+
// Only the size, position, and z-order change.
143+
const LONG_PTR cur_style = GetWindowLongPtr(hwnd, GWL_STYLE);
144+
145+
// Compute the window rect that gives the desired client video area.
146+
// AdjustWindowRect accounts for title bar + borders in cur_style.
147+
RECT wr{0, 0, 380, static_cast<LONG>(380.0 / aspect_ratio + 0.5)};
148+
AdjustWindowRect(&wr, static_cast<DWORD>(cur_style), FALSE);
149+
const int pip_w = wr.right - wr.left;
150+
const int pip_h = wr.bottom - wr.top;
145151

146152
// Position at bottom-right of the monitor work area.
147153
MONITORINFO mi{sizeof(mi)};
@@ -151,10 +157,8 @@ void FlutterWindow::SetupPipChannel() {
151157
const int pip_x = mi.rcWork.right - pip_w - kMargin;
152158
const int pip_y = mi.rcWork.bottom - pip_h - kMargin;
153159

154-
// Borderless with a thin resize frame (no title bar).
155-
SetWindowLongPtr(hwnd, GWL_STYLE, WS_POPUP | WS_THICKFRAME);
156160
SetWindowPos(hwnd, HWND_TOPMOST, pip_x, pip_y, pip_w, pip_h,
157-
SWP_NOOWNERZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
161+
SWP_NOOWNERZORDER | SWP_SHOWWINDOW);
158162
is_pip_ = true;
159163

160164
if (pip_channel_) {
@@ -168,11 +172,10 @@ void FlutterWindow::SetupPipChannel() {
168172
call.method_name() == "exit") {
169173
if (is_pip_) {
170174
is_pip_ = false;
171-
SetWindowLongPtr(hwnd, GWL_STYLE, pip_saved_style_);
172175
SetWindowPlacement(hwnd, &pip_saved_placement_);
173176
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
174177
SWP_NOMOVE | SWP_NOSIZE | SWP_NOOWNERZORDER |
175-
SWP_FRAMECHANGED | SWP_SHOWWINDOW);
178+
SWP_SHOWWINDOW);
176179
SetForegroundWindow(hwnd);
177180

178181
if (pip_channel_) {
@@ -209,17 +212,6 @@ LRESULT
209212
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
210213
WPARAM const wparam,
211214
LPARAM const lparam) noexcept {
212-
// In PiP mode the top 28 px acts as a drag strip so the frameless window
213-
// can still be moved by the user. Handle this before Flutter sees it.
214-
if (message == WM_NCHITTEST && is_pip_) {
215-
const int py = static_cast<int>(static_cast<short>(HIWORD(lparam)));
216-
RECT rc;
217-
GetWindowRect(hwnd, &rc);
218-
if (py < rc.top + 28) {
219-
return HTCAPTION;
220-
}
221-
}
222-
223215
switch (message) {
224216
case WM_CLOSE:
225217
if (!is_pip_) SaveWindowGeometry(hwnd);

windows/runner/flutter_window.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ class FlutterWindow : public Win32Window {
5050

5151
// PiP state.
5252
bool is_pip_ = false;
53-
LONG_PTR pip_saved_style_ = 0;
5453
WINDOWPLACEMENT pip_saved_placement_{};
5554
};
5655

0 commit comments

Comments
 (0)