DocumentPictureInPicture.cpp (13612B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 5 #include "mozilla/dom/DocumentPictureInPicture.h" 6 7 #include "mozilla/AsyncEventDispatcher.h" 8 #include "mozilla/WidgetUtils.h" 9 #include "mozilla/dom/BrowserChild.h" 10 #include "mozilla/dom/Document.h" 11 #include "mozilla/dom/DocumentPictureInPictureEvent.h" 12 #include "mozilla/dom/WindowContext.h" 13 #include "mozilla/widget/Screen.h" 14 #include "nsDocShell.h" 15 #include "nsDocShellLoadState.h" 16 #include "nsIWindowWatcher.h" 17 #include "nsNetUtil.h" 18 #include "nsPIWindowWatcher.h" 19 #include "nsServiceManagerUtils.h" 20 #include "nsWindowWatcher.h" 21 22 namespace mozilla::dom { 23 24 static mozilla::LazyLogModule gDPIPLog("DocumentPIP"); 25 26 NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentPictureInPicture) 27 28 NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentPictureInPicture, 29 DOMEventTargetHelper) 30 NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLastOpenedWindow) 31 NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END 32 33 NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentPictureInPicture, 34 DOMEventTargetHelper) 35 NS_IMPL_CYCLE_COLLECTION_UNLINK(mLastOpenedWindow) 36 NS_IMPL_CYCLE_COLLECTION_UNLINK_END 37 38 NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentPictureInPicture) 39 NS_INTERFACE_MAP_ENTRY(nsIObserver) 40 NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener) 41 NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) 42 43 NS_IMPL_ADDREF_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) 44 NS_IMPL_RELEASE_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) 45 46 JSObject* DocumentPictureInPicture::WrapObject( 47 JSContext* cx, JS::Handle<JSObject*> aGivenProto) { 48 return DocumentPictureInPicture_Binding::Wrap(cx, this, aGivenProto); 49 } 50 51 DocumentPictureInPicture::DocumentPictureInPicture(nsPIDOMWindowInner* aWindow) 52 : DOMEventTargetHelper(aWindow) { 53 nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService(); 54 NS_ENSURE_TRUE_VOID(os); 55 DebugOnly<nsresult> rv = os->AddObserver(this, "domwindowclosed", false); 56 MOZ_ASSERT(NS_SUCCEEDED(rv)); 57 } 58 59 DocumentPictureInPicture::~DocumentPictureInPicture() { 60 nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService(); 61 NS_ENSURE_TRUE_VOID(os); 62 DebugOnly<nsresult> rv = os->RemoveObserver(this, "domwindowclosed"); 63 MOZ_ASSERT(NS_SUCCEEDED(rv)); 64 } 65 66 void DocumentPictureInPicture::OnPiPResized() { 67 if (!mLastOpenedWindow) { 68 return; 69 } 70 71 RefPtr<nsGlobalWindowInner> innerWindow = 72 nsGlobalWindowInner::Cast(mLastOpenedWindow); 73 74 int x = innerWindow->GetScreenLeft(CallerType::System, IgnoreErrors()); 75 int y = innerWindow->GetScreenTop(CallerType::System, IgnoreErrors()); 76 int width = static_cast<int>(innerWindow->GetInnerWidth(IgnoreErrors())); 77 int height = static_cast<int>(innerWindow->GetInnerHeight(IgnoreErrors())); 78 79 mPreviousExtent = Some(CSSIntRect(x, y, width, height)); 80 81 MOZ_LOG(gDPIPLog, LogLevel::Debug, 82 ("PiP was resized, remembering position %s", 83 ToString(mPreviousExtent).c_str())); 84 } 85 86 void DocumentPictureInPicture::OnPiPClosed() { 87 if (!mLastOpenedWindow) { 88 return; 89 } 90 91 RefPtr<nsGlobalWindowInner> pipInnerWindow = 92 nsGlobalWindowInner::Cast(mLastOpenedWindow); 93 pipInnerWindow->RemoveSystemEventListener(u"resize"_ns, this, true); 94 95 MOZ_LOG(gDPIPLog, LogLevel::Debug, ("PiP was closed")); 96 97 mLastOpenedWindow = nullptr; 98 } 99 100 nsGlobalWindowInner* DocumentPictureInPicture::GetWindow() { 101 if (mLastOpenedWindow && mLastOpenedWindow->GetOuterWindow() && 102 !mLastOpenedWindow->GetOuterWindow()->Closed()) { 103 return nsGlobalWindowInner::Cast(mLastOpenedWindow); 104 } 105 return nullptr; 106 } 107 108 // Some sane default. Maybe we should come up with an heuristic based on screen 109 // size. 110 const CSSIntSize DocumentPictureInPicture::sDefaultSize = {700, 650}; 111 const CSSIntSize DocumentPictureInPicture::sMinSize = {240, 50}; 112 113 static nsresult OpenPiPWindowUtility(nsPIDOMWindowOuter* aParent, 114 const CSSIntRect& aExtent, bool aPrivate, 115 mozilla::dom::BrowsingContext** aRet) { 116 MOZ_DIAGNOSTIC_ASSERT(aParent); 117 118 nsresult rv = NS_OK; 119 nsCOMPtr<nsIWindowWatcher> ww = 120 do_GetService(NS_WINDOWWATCHER_CONTRACTID, &rv); 121 NS_ENSURE_SUCCESS(rv, rv); 122 123 nsCOMPtr<nsPIWindowWatcher> pww(do_QueryInterface(ww)); 124 NS_ENSURE_TRUE(pww, NS_ERROR_FAILURE); 125 126 nsCOMPtr<nsIURI> uri; 127 rv = NS_NewURI(getter_AddRefs(uri), "about:blank"_ns, nullptr); 128 NS_ENSURE_SUCCESS(rv, rv); 129 130 RefPtr<nsDocShellLoadState> loadState = 131 nsWindowWatcher::CreateLoadState(uri, aParent); 132 133 // pictureinpicture is a non-standard window feature not available from JS 134 nsPrintfCString features("pictureinpicture,top=%d,left=%d,width=%d,height=%d", 135 aExtent.y, aExtent.x, aExtent.width, aExtent.height); 136 137 rv = pww->OpenWindow2(aParent, uri, "_blank"_ns, features, 138 mozilla::dom::UserActivation::Modifiers::None(), false, 139 false, true, nullptr, false, false, false, 140 nsPIWindowWatcher::PrintKind::PRINT_NONE, loadState, 141 aRet); 142 NS_ENSURE_SUCCESS(rv, rv); 143 NS_ENSURE_TRUE(aRet, NS_ERROR_FAILURE); 144 return NS_OK; 145 } 146 147 /* static */ 148 Maybe<CSSIntRect> DocumentPictureInPicture::GetScreenRect( 149 nsPIDOMWindowOuter* aWindow) { 150 nsCOMPtr<nsIWidget> widget = widget::WidgetUtils::DOMWindowToWidget(aWindow); 151 NS_ENSURE_TRUE(widget, Nothing()); 152 RefPtr<widget::Screen> screen = widget->GetWidgetScreen(); 153 NS_ENSURE_TRUE(screen, Nothing()); 154 LayoutDeviceIntRect rect = screen->GetRect(); 155 156 nsGlobalWindowOuter* outerWindow = nsGlobalWindowOuter::Cast(aWindow); 157 NS_ENSURE_TRUE(outerWindow, Nothing()); 158 nsCOMPtr<nsIBaseWindow> treeOwnerAsWin = outerWindow->GetTreeOwnerWindow(); 159 NS_ENSURE_TRUE(treeOwnerAsWin, Nothing()); 160 auto scale = outerWindow->CSSToDevScaleForBaseWindow(treeOwnerAsWin); 161 162 return Some(RoundedToInt(rect / scale)); 163 } 164 165 // Place window in the bottom right of the opener window's screen 166 static CSSIntPoint CalcInitialPos(const CSSIntRect& screen, 167 const CSSIntSize& aSize) { 168 // aSize is the inner size not including browser UI. But we need the outer 169 // size for calculating where the top left corner of the PiP should be 170 // initially. For now use a guess of ~80px for the browser UI? 171 return {std::max(screen.X(), screen.XMost() - aSize.width - 100), 172 std::max(screen.Y(), screen.YMost() - aSize.height - 100 - 80)}; 173 } 174 175 /* static */ 176 CSSIntSize DocumentPictureInPicture::CalcMaxDimensions( 177 const CSSIntRect& screen) { 178 // Limit PIP size to 80% (arbitrary number) of screen size 179 // https://wicg.github.io/document-picture-in-picture/#maximum-size 180 CSSIntSize size = 181 RoundedToInt(screen.Size() * gfx::ScaleFactor<CSSPixel, CSSPixel>(0.8)); 182 size.width = std::max(size.width, sMinSize.width); 183 size.height = std::max(size.height, sMinSize.height); 184 return size; 185 } 186 187 CSSIntRect DocumentPictureInPicture::DetermineExtent( 188 bool aPreferInitialWindowPlacement, int aRequestedWidth, 189 int aRequestedHeight, const CSSIntRect& screen) { 190 // If we remembered an extent, don't preferInitialWindowPlacement, and the 191 // requested size didn't change, then restore the remembered extent. 192 const bool shouldUseInitialPlacement = 193 !mPreviousExtent.isSome() || aPreferInitialWindowPlacement || 194 (mLastRequestedSize.isSome() && 195 (mLastRequestedSize->Width() != aRequestedWidth || 196 mLastRequestedSize->Height() != aRequestedHeight)); 197 198 CSSIntRect extent; 199 if (shouldUseInitialPlacement) { 200 CSSIntSize size = sDefaultSize; 201 if (aRequestedWidth > 0 && aRequestedHeight > 0) { 202 size = CSSIntSize(aRequestedWidth, aRequestedHeight); 203 } 204 CSSIntPoint initialPos = CalcInitialPos(screen, size); 205 extent = CSSIntRect(initialPos, size); 206 207 MOZ_LOG(gDPIPLog, LogLevel::Debug, 208 ("Calculated initial PiP rect %s", ToString(extent).c_str())); 209 } else { 210 extent = mPreviousExtent.value(); 211 } 212 213 // https://wicg.github.io/document-picture-in-picture/#maximum-size 214 CSSIntSize maxSize = CalcMaxDimensions(screen); 215 extent.width = std::clamp(extent.width, sMinSize.width, maxSize.width); 216 extent.height = std::clamp(extent.height, sMinSize.height, maxSize.height); 217 218 return extent; 219 } 220 221 already_AddRefed<Promise> DocumentPictureInPicture::RequestWindow( 222 const DocumentPictureInPictureOptions& aOptions, ErrorResult& aRv) { 223 // Not part of the spec, but check the document is active 224 RefPtr<nsPIDOMWindowInner> ownerWin = GetOwnerWindow(); 225 if (!ownerWin || !ownerWin->IsFullyActive()) { 226 aRv.ThrowNotAllowedError("Document is not fully active"); 227 return nullptr; 228 } 229 230 // 2. Throw if not top-level 231 BrowsingContext* bc = ownerWin->GetBrowsingContext(); 232 if (!bc || !bc->IsTop()) { 233 aRv.ThrowNotAllowedError( 234 "Document Picture-in-Picture is only available in top-level contexts"); 235 return nullptr; 236 } 237 238 // 3. Throw if already in a Document PIP window 239 if (bc->GetIsDocumentPiP()) { 240 aRv.ThrowNotAllowedError( 241 "Cannot open a Picture-in-Picture window from inside one"); 242 return nullptr; 243 } 244 245 // 4, 7. Require transient activation 246 WindowContext* wc = ownerWin->GetWindowContext(); 247 if (!wc || !wc->ConsumeTransientUserGestureActivation()) { 248 aRv.ThrowNotAllowedError( 249 "Document Picture-in-Picture requires user activation"); 250 return nullptr; 251 } 252 253 // 5-6. If width or height is given, both must be specified 254 if ((aOptions.mWidth > 0) != (aOptions.mHeight > 0)) { 255 aRv.ThrowRangeError( 256 "requestWindow: width and height must be specified together"); 257 return nullptr; 258 } 259 260 // 8. Possibly close last opened window 261 if (RefPtr<nsPIDOMWindowInner> lastOpenedWindow = mLastOpenedWindow) { 262 lastOpenedWindow->Close(); 263 } 264 265 CSSIntRect screen; 266 if (Maybe<CSSIntRect> maybeScreen = 267 GetScreenRect(ownerWin->GetOuterWindow())) { 268 screen = maybeScreen.value(); 269 } else { 270 aRv.ThrowRangeError("Could not determine screen for window"); 271 return nullptr; 272 } 273 274 // 13-15. Determine PiP extent 275 const int requestedWidth = SaturatingCast<int>(aOptions.mWidth), 276 requestedHeight = SaturatingCast<int>(aOptions.mHeight); 277 CSSIntRect extent = DetermineExtent(aOptions.mPreferInitialWindowPlacement, 278 requestedWidth, requestedHeight, screen); 279 mLastRequestedSize = Some(CSSIntSize(requestedWidth, requestedHeight)); 280 281 MOZ_LOG(gDPIPLog, LogLevel::Debug, 282 ("Will place PiP at rect %s", ToString(extent).c_str())); 283 284 // 9. Optionally, close any existing PIP windows 285 // I think it's useful to have multiple PiP windows from different top pages. 286 287 // 15. aOptions.mDisallowReturnToOpener 288 // I think this button is redundant with close and the webpage won't know 289 // whether close or return was pressed. So let's not have that button at all. 290 291 // 10. Create a new top-level traversable for target _blank 292 // 16. Configure PIP to float on top via window features 293 RefPtr<BrowsingContext> pipTraversable; 294 nsresult rv = OpenPiPWindowUtility(ownerWin->GetOuterWindow(), extent, 295 bc->UsePrivateBrowsing(), 296 getter_AddRefs(pipTraversable)); 297 if (NS_FAILED(rv)) { 298 aRv.ThrowUnknownError("Failed to create PIP window"); 299 return nullptr; 300 } 301 302 // 11. Set PIP's active document's mode to this's document's mode 303 pipTraversable->GetDocument()->SetCompatibilityMode( 304 ownerWin->GetDoc()->GetCompatibilityMode()); 305 306 // 12. Set PIP's IsDocumentPIP flag 307 rv = pipTraversable->SetIsDocumentPiP(true); 308 MOZ_ASSERT(NS_SUCCEEDED(rv)); 309 310 // 16. Set mLastOpenedWindow 311 mLastOpenedWindow = pipTraversable->GetDOMWindow()->GetCurrentInnerWindow(); 312 MOZ_ASSERT(mLastOpenedWindow); 313 314 // Keep track of resizes to update mPreviousExtent 315 RefPtr<nsGlobalWindowInner> pipInnerWindow = 316 nsGlobalWindowInner::Cast(mLastOpenedWindow); 317 pipInnerWindow->AddSystemEventListener(u"resize"_ns, this, true, false); 318 319 // 17. Queue a task to fire a DocumentPictureInPictureEvent named "enter" on 320 // this with pipTraversable as it's window attribute 321 DocumentPictureInPictureEventInit eventInit; 322 eventInit.mWindow = pipInnerWindow; 323 RefPtr<Event> event = 324 DocumentPictureInPictureEvent::Constructor(this, u"enter"_ns, eventInit); 325 RefPtr<AsyncEventDispatcher> asyncDispatcher = 326 new AsyncEventDispatcher(this, event.forget()); 327 asyncDispatcher->PostDOMEvent(); 328 329 // 18. Return pipTraversable 330 RefPtr<Promise> promise = Promise::CreateInfallible(GetOwnerGlobal()); 331 promise->MaybeResolve(pipInnerWindow); 332 return promise.forget(); 333 } 334 335 NS_IMETHODIMP 336 DocumentPictureInPicture::HandleEvent(Event* aEvent) { 337 nsAutoString type; 338 aEvent->GetType(type); 339 340 if (type.EqualsLiteral("resize")) { 341 OnPiPResized(); 342 return NS_OK; 343 } 344 345 return NS_OK; 346 } 347 348 NS_IMETHODIMP DocumentPictureInPicture::Observe(nsISupports* aSubject, 349 const char* aTopic, 350 const char16_t* aData) { 351 if (nsCRT::strcmp(aTopic, "domwindowclosed") == 0) { 352 nsCOMPtr<nsPIDOMWindowOuter> subjectWin = do_QueryInterface(aSubject); 353 NS_ENSURE_TRUE(!!subjectWin, NS_OK); 354 355 if (subjectWin->GetCurrentInnerWindow() == mLastOpenedWindow) { 356 OnPiPClosed(); 357 } 358 } 359 return NS_OK; 360 } 361 362 } // namespace mozilla::dom