/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ #include "mozilla/dom/DocumentPictureInPicture.h" #include "mozilla/AsyncEventDispatcher.h" #include "mozilla/WidgetUtils.h" #include "mozilla/dom/BrowserChild.h" #include "mozilla/dom/Document.h" #include "mozilla/dom/DocumentPictureInPictureEvent.h" #include "mozilla/dom/WindowContext.h" #include "mozilla/widget/Screen.h" #include "nsDocShell.h" #include "nsDocShellLoadState.h" #include "nsIWindowWatcher.h" #include "nsNetUtil.h" #include "nsPIWindowWatcher.h" #include "nsServiceManagerUtils.h" #include "nsWindowWatcher.h" namespace mozilla::dom { static mozilla::LazyLogModule gDPIPLog("DocumentPIP"); NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentPictureInPicture) NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLastOpenedWindow) NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) NS_IMPL_CYCLE_COLLECTION_UNLINK(mLastOpenedWindow) NS_IMPL_CYCLE_COLLECTION_UNLINK_END NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentPictureInPicture) NS_INTERFACE_MAP_ENTRY(nsIObserver) NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) NS_IMPL_ADDREF_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) NS_IMPL_RELEASE_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) JSObject* DocumentPictureInPicture::WrapObject( JSContext* cx, JS::Handle aGivenProto) { return DocumentPictureInPicture_Binding::Wrap(cx, this, aGivenProto); } DocumentPictureInPicture::DocumentPictureInPicture(nsPIDOMWindowInner* aWindow) : DOMEventTargetHelper(aWindow) { nsCOMPtr os = mozilla::services::GetObserverService(); NS_ENSURE_TRUE_VOID(os); MOZ_ALWAYS_SUCCEEDS(os->AddObserver(this, "domwindowclosed", false)); MOZ_ALWAYS_SUCCEEDS( os->AddObserver(this, "docshell-position-size-changed", false)); } DocumentPictureInPicture::~DocumentPictureInPicture() { nsCOMPtr os = mozilla::services::GetObserverService(); NS_ENSURE_TRUE_VOID(os); MOZ_ALWAYS_SUCCEEDS(os->RemoveObserver(this, "domwindowclosed")); MOZ_ALWAYS_SUCCEEDS( os->RemoveObserver(this, "docshell-position-size-changed")); } void DocumentPictureInPicture::OnPiPResized() { if (!mLastOpenedWindow) { return; } RefPtr innerWindow = nsGlobalWindowInner::Cast(mLastOpenedWindow); int x = innerWindow->GetScreenLeft(CallerType::System, IgnoreErrors()); int y = innerWindow->GetScreenTop(CallerType::System, IgnoreErrors()); int width = static_cast(innerWindow->GetInnerWidth(IgnoreErrors())); int height = static_cast(innerWindow->GetInnerHeight(IgnoreErrors())); mPreviousExtent = Some(CSSIntRect(x, y, width, height)); MOZ_LOG(gDPIPLog, LogLevel::Debug, ("PiP was resized, remembering position %s", ToString(mPreviousExtent).c_str())); } void DocumentPictureInPicture::OnPiPClosed() { if (!mLastOpenedWindow) { return; } MOZ_LOG(gDPIPLog, LogLevel::Debug, ("PiP was closed")); mLastOpenedWindow = nullptr; } nsGlobalWindowInner* DocumentPictureInPicture::GetWindow() { if (mLastOpenedWindow && mLastOpenedWindow->GetOuterWindow() && !mLastOpenedWindow->GetOuterWindow()->Closed()) { return nsGlobalWindowInner::Cast(mLastOpenedWindow); } return nullptr; } // Some sane default. const CSSIntSize DocumentPictureInPicture::sDefaultSize = {400, 300}; const CSSIntSize DocumentPictureInPicture::sMinSize = {240, 50}; static nsresult OpenPiPWindowUtility(nsPIDOMWindowOuter* aParent, const CSSIntRect& aExtent, bool aPrivate, mozilla::dom::BrowsingContext** aRet) { MOZ_DIAGNOSTIC_ASSERT(aParent); nsresult rv = NS_OK; nsCOMPtr ww = do_GetService(NS_WINDOWWATCHER_CONTRACTID, &rv); NS_ENSURE_SUCCESS(rv, rv); nsCOMPtr pww(do_QueryInterface(ww)); NS_ENSURE_TRUE(pww, NS_ERROR_FAILURE); nsCOMPtr uri; rv = NS_NewURI(getter_AddRefs(uri), "about:blank"_ns, nullptr); NS_ENSURE_SUCCESS(rv, rv); RefPtr loadState = nsWindowWatcher::CreateLoadState(uri, aParent); // pictureinpicture is a non-standard window feature not available from JS nsPrintfCString features("pictureinpicture,top=%d,left=%d,width=%d,height=%d", aExtent.y, aExtent.x, aExtent.width, aExtent.height); rv = pww->OpenWindow2(aParent, uri, "_blank"_ns, features, mozilla::dom::UserActivation::Modifiers::None(), false, false, true, nullptr, false, false, false, nsPIWindowWatcher::PrintKind::PRINT_NONE, loadState, aRet); NS_ENSURE_SUCCESS(rv, rv); NS_ENSURE_TRUE(aRet, NS_ERROR_FAILURE); return NS_OK; } /* static */ Maybe DocumentPictureInPicture::GetScreenRect( nsPIDOMWindowOuter* aWindow) { nsCOMPtr widget = widget::WidgetUtils::DOMWindowToWidget(aWindow); NS_ENSURE_TRUE(widget, Nothing()); RefPtr screen = widget->GetWidgetScreen(); NS_ENSURE_TRUE(screen, Nothing()); LayoutDeviceIntRect rect = screen->GetRect(); nsGlobalWindowOuter* outerWindow = nsGlobalWindowOuter::Cast(aWindow); NS_ENSURE_TRUE(outerWindow, Nothing()); nsCOMPtr treeOwnerAsWin = outerWindow->GetTreeOwnerWindow(); NS_ENSURE_TRUE(treeOwnerAsWin, Nothing()); auto scale = outerWindow->CSSToDevScaleForBaseWindow(treeOwnerAsWin); return Some(RoundedToInt(rect / scale)); } // Place window in the bottom right of the opener window's screen static CSSIntRect CalcInitialExtent(const CSSIntRect& aScreen, const CSSIntSize& aSize) { // aSize is the inner size not including browser UI. But we need the outer // size for calculating where the top left corner of the PiP should be // initially. For now use a guess of ~80px for the browser UI? const CSSIntPoint pos = { std::max(aScreen.X(), aScreen.XMost() - aSize.width - 100), std::max(aScreen.Y(), aScreen.YMost() - aSize.height - 100 - 80)}; return CSSIntRect(pos, aSize); } /* static */ CSSIntSize DocumentPictureInPicture::CalcMaxDimensions( const CSSIntRect& aScreen) { // Limit PIP size to 80% (arbitrary number) of screen size // https://wicg.github.io/document-picture-in-picture/#maximum-size CSSIntSize size = RoundedToInt(aScreen.Size() * gfx::ScaleFactor(0.8)); size.width = std::max(size.width, sMinSize.width); size.height = std::max(size.height, sMinSize.height); return size; } CSSIntRect DocumentPictureInPicture::DetermineExtent( bool aPreferInitialWindowPlacement, const CSSIntSize& aRequestedSize, const CSSIntRect& aScreen) { // The user agent may use the previous position and size. // https://wicg.github.io/document-picture-in-picture/#example-prefer-initial-window-placement // If no width/height was specified (it's 0), consider it unchanged too. const bool emptyRequest = aRequestedSize == CSSIntSize(0, 0); const bool requestChanged = !emptyRequest && (mLastRequestedSize.isNothing() || *mLastRequestedSize != aRequestedSize); if (!emptyRequest) { mLastRequestedSize = Some(aRequestedSize); } // If we remembered an extent, don't preferInitialWindowPlacement, and the // requested size didn't change, then restore the remembered extent. const bool reusePreviousExtent = mPreviousExtent.isSome() && !aPreferInitialWindowPlacement && !requestChanged; MOZ_LOG_FMT(gDPIPLog, LogLevel::Debug, "{} reuse previous extent (hasPrevious={}, preferInitial={}, " "requestChanged={})", reusePreviousExtent ? "Will" : "Won't", mPreviousExtent.isSome(), aPreferInitialWindowPlacement, requestChanged); CSSIntRect extent; if (reusePreviousExtent) { extent = mPreviousExtent.value(); } else { extent = CalcInitialExtent(aScreen, emptyRequest ? sDefaultSize : aRequestedSize); MOZ_LOG(gDPIPLog, LogLevel::Debug, ("Calculated initial PiP rect %s", ToString(extent).c_str())); } // https://wicg.github.io/document-picture-in-picture/#maximum-size CSSIntSize maxSize = CalcMaxDimensions(aScreen); extent.width = std::clamp(extent.width, sMinSize.width, maxSize.width); extent.height = std::clamp(extent.height, sMinSize.height, maxSize.height); return extent; } already_AddRefed DocumentPictureInPicture::RequestWindow( const DocumentPictureInPictureOptions& aOptions, ErrorResult& aRv) { // Not part of the spec, but check the document is active RefPtr ownerWin = GetOwnerWindow(); if (!ownerWin || !ownerWin->IsFullyActive()) { aRv.ThrowNotAllowedError("Document is not fully active"); return nullptr; } // 2. Throw if not top-level BrowsingContext* bc = ownerWin->GetBrowsingContext(); if (!bc || !bc->IsTop()) { aRv.ThrowNotAllowedError( "Document Picture-in-Picture is only available in top-level contexts"); return nullptr; } // 3. Throw if already in a Document PIP window if (bc->GetIsDocumentPiP()) { aRv.ThrowNotAllowedError( "Cannot open a Picture-in-Picture window from inside one"); return nullptr; } // 4, 7. Require transient activation WindowContext* wc = ownerWin->GetWindowContext(); if (!wc || !wc->ConsumeTransientUserGestureActivation()) { aRv.ThrowNotAllowedError( "Document Picture-in-Picture requires user activation"); return nullptr; } // 5-6. If width or height is given, both must be specified if ((aOptions.mWidth > 0) != (aOptions.mHeight > 0)) { aRv.ThrowRangeError( "requestWindow: width and height must be specified together"); return nullptr; } // 8. Possibly close last opened window if (RefPtr lastOpenedWindow = mLastOpenedWindow) { lastOpenedWindow->Close(); } CSSIntRect screen; if (Maybe maybeScreen = GetScreenRect(ownerWin->GetOuterWindow())) { screen = maybeScreen.value(); } else { aRv.ThrowRangeError("Could not determine screen for window"); return nullptr; } // 13-15. Determine PiP extent const CSSIntSize requestedSize = {SaturatingCast(aOptions.mWidth), SaturatingCast(aOptions.mHeight)}; CSSIntRect extent = DetermineExtent(aOptions.mPreferInitialWindowPlacement, requestedSize, screen); MOZ_LOG(gDPIPLog, LogLevel::Debug, ("Will place PiP at rect %s", ToString(extent).c_str())); // 9. Optionally, close any existing PIP windows // I think it's useful to have multiple PiP windows from different top pages. // 15. aOptions.mDisallowReturnToOpener // I think this button is redundant with close and the webpage won't know // whether close or return was pressed. So let's not have that button at all. // 10. Create a new top-level traversable for target _blank // 16. Configure PIP to float on top via window features RefPtr pipTraversable; nsresult rv = OpenPiPWindowUtility(ownerWin->GetOuterWindow(), extent, bc->UsePrivateBrowsing(), getter_AddRefs(pipTraversable)); if (NS_FAILED(rv)) { aRv.ThrowUnknownError("Failed to create PIP window"); return nullptr; } // 11. Set PIP's active document's mode to this's document's mode pipTraversable->GetDocument()->SetCompatibilityMode( ownerWin->GetDoc()->GetCompatibilityMode()); // 12. Set PIP's IsDocumentPIP flag rv = pipTraversable->SetIsDocumentPiP(true); MOZ_ASSERT(NS_SUCCEEDED(rv)); // 16. Set mLastOpenedWindow mLastOpenedWindow = pipTraversable->GetDOMWindow()->GetCurrentInnerWindow(); MOZ_ASSERT(mLastOpenedWindow); // 17. Queue a task to fire a DocumentPictureInPictureEvent named "enter" on // this with pipTraversable as it's window attribute DocumentPictureInPictureEventInit eventInit; eventInit.mWindow = nsGlobalWindowInner::Cast(mLastOpenedWindow); RefPtr event = DocumentPictureInPictureEvent::Constructor(this, u"enter"_ns, eventInit); RefPtr asyncDispatcher = new AsyncEventDispatcher(this, event.forget()); asyncDispatcher->PostDOMEvent(); // 18. Return pipTraversable RefPtr promise = Promise::CreateInfallible(GetOwnerGlobal()); promise->MaybeResolve(nsGlobalWindowInner::Cast(mLastOpenedWindow)); return promise.forget(); } NS_IMETHODIMP DocumentPictureInPicture::Observe(nsISupports* aSubject, const char* aTopic, const char16_t* aData) { if (!mLastOpenedWindow) { return NS_OK; } if (nsCRT::strcmp(aTopic, "domwindowclosed") == 0) { nsCOMPtr subjectWin = do_QueryInterface(aSubject); NS_ENSURE_TRUE(!!subjectWin, NS_OK); if (subjectWin->GetCurrentInnerWindow() == mLastOpenedWindow) { OnPiPClosed(); } } else if (nsCRT::strcmp(aTopic, "docshell-position-size-changed") == 0) { nsCOMPtr docshell = do_QueryInterface(aSubject); NS_ENSURE_TRUE(!!docshell, NS_OK); BrowsingContext* bc = docshell->GetBrowsingContext(); if (!bc || !bc->GetIsDocumentPiP()) { return NS_OK; } if (bc == mLastOpenedWindow->GetBrowsingContext()) { // Async invocation due to MOZ_CAN_RUN_SCRIPT NS_DispatchToMainThread(NS_NewRunnableFunction( "DocumentPictureInPicture::OnPiPResized", [_self = RefPtr(this)]() MOZ_CAN_RUN_SCRIPT_BOUNDARY_LAMBDA { _self->OnPiPResized(); })); } } return NS_OK; } } // namespace mozilla::dom