Browse Source

feat(fullscreen videos): Support fullscreen video on Android (#325)

* Extract WebChromeClient from an anonymous class

* Support fullscreen videos on Android

Forces landscape mode while playing.

* Use sticky immersive mode for fullscreen videos

No longer forces landscape mode as that is a problem for portrait videos - allow
the user to rotate as necessary.

Only supports KitKat or greater, and falls back to leaving the status and navigation
bars visible for lower than KitKat. This is the easiest way to prevent issues with
resizing the video during playback.

Also implement a lifecyle event listener which means if a user backgrounds the app
or locks the screen with the video fullscreen, the UI visibility is re-applied.

* Add allowsFullscreenVideo prop to control whether videos can be fullscreen on Android

Luckily, we're able to change the WebChromeClient on demand in response to prop changes
without seeming to do any harm. If you switch to disallow fullscreen, it will attempt
to close the currently fullscreened video (if there is one) so users aren't stuck.

I did notice a bug that if you go from fullscreen allowed, to fullscreen disallowed,
the fullscreen button will remain on the video. Tapping the button will have no effect.
Daniel Vicory 5 years ago
parent
commit
d72c2ae144

+ 169
- 54
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java View File

6
 import android.content.Context;
6
 import android.content.Context;
7
 import android.content.Intent;
7
 import android.content.Intent;
8
 import android.graphics.Bitmap;
8
 import android.graphics.Bitmap;
9
+import android.graphics.Color;
9
 import android.net.Uri;
10
 import android.net.Uri;
10
 import android.os.Build;
11
 import android.os.Build;
11
 import android.os.Environment;
12
 import android.os.Environment;
13
+import android.support.annotation.RequiresApi;
12
 import android.text.TextUtils;
14
 import android.text.TextUtils;
15
+import android.view.Gravity;
13
 import android.view.View;
16
 import android.view.View;
17
+import android.view.ViewGroup;
14
 import android.view.ViewGroup.LayoutParams;
18
 import android.view.ViewGroup.LayoutParams;
19
+import android.view.WindowManager;
15
 import android.webkit.ConsoleMessage;
20
 import android.webkit.ConsoleMessage;
16
 import android.webkit.CookieManager;
21
 import android.webkit.CookieManager;
17
 import android.webkit.DownloadListener;
22
 import android.webkit.DownloadListener;
24
 import android.webkit.WebSettings;
29
 import android.webkit.WebSettings;
25
 import android.webkit.WebView;
30
 import android.webkit.WebView;
26
 import android.webkit.WebViewClient;
31
 import android.webkit.WebViewClient;
32
+import android.widget.FrameLayout;
27
 
33
 
28
 import com.facebook.react.bridge.Arguments;
34
 import com.facebook.react.bridge.Arguments;
29
 import com.facebook.react.bridge.LifecycleEventListener;
35
 import com.facebook.react.bridge.LifecycleEventListener;
106
   protected static final String BLANK_URL = "about:blank";
112
   protected static final String BLANK_URL = "about:blank";
107
   protected WebViewConfig mWebViewConfig;
113
   protected WebViewConfig mWebViewConfig;
108
 
114
 
115
+  protected RNCWebChromeClient mWebChromeClient = null;
116
+  protected boolean mAllowsFullscreenVideo = false;
117
+
109
   public RNCWebViewManager() {
118
   public RNCWebViewManager() {
110
     mWebViewConfig = new WebViewConfig() {
119
     mWebViewConfig = new WebViewConfig() {
111
       public void configWebView(WebView webView) {
120
       public void configWebView(WebView webView) {
137
   @TargetApi(Build.VERSION_CODES.LOLLIPOP)
146
   @TargetApi(Build.VERSION_CODES.LOLLIPOP)
138
   protected WebView createViewInstance(ThemedReactContext reactContext) {
147
   protected WebView createViewInstance(ThemedReactContext reactContext) {
139
     RNCWebView webView = createRNCWebViewInstance(reactContext);
148
     RNCWebView webView = createRNCWebViewInstance(reactContext);
140
-    webView.setWebChromeClient(new WebChromeClient() {
141
-      @Override
142
-      public boolean onConsoleMessage(ConsoleMessage message) {
143
-        if (ReactBuildConfig.DEBUG) {
144
-          return super.onConsoleMessage(message);
145
-        }
146
-        // Ignore console logs in non debug builds.
147
-        return true;
148
-      }
149
-
150
-
151
-      @Override
152
-      public void onProgressChanged(WebView webView, int newProgress) {
153
-        super.onProgressChanged(webView, newProgress);
154
-        WritableMap event = Arguments.createMap();
155
-        event.putDouble("target", webView.getId());
156
-        event.putString("title", webView.getTitle());
157
-        event.putBoolean("canGoBack", webView.canGoBack());
158
-        event.putBoolean("canGoForward", webView.canGoForward());
159
-        event.putDouble("progress", (float) newProgress / 100);
160
-        dispatchEvent(
161
-          webView,
162
-          new TopLoadingProgressEvent(
163
-            webView.getId(),
164
-            event));
165
-      }
166
-
167
-      @Override
168
-      public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) {
169
-        callback.invoke(origin, true, false);
170
-      }
171
-
172
-      protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType) {
173
-        getModule(reactContext).startPhotoPickerIntent(filePathCallback, acceptType);
174
-      }
175
-
176
-      protected void openFileChooser(ValueCallback<Uri> filePathCallback) {
177
-        getModule(reactContext).startPhotoPickerIntent(filePathCallback, "");
178
-      }
179
-
180
-      protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType, String capture) {
181
-        getModule(reactContext).startPhotoPickerIntent(filePathCallback, acceptType);
182
-      }
183
-
184
-      @TargetApi(Build.VERSION_CODES.LOLLIPOP)
185
-      @Override
186
-      public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
187
-        String[] acceptTypes = fileChooserParams.getAcceptTypes();
188
-        boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE;
189
-        Intent intent = fileChooserParams.createIntent();
190
-        return getModule(reactContext).startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple);
191
-      }
192
-    });
149
+    setupWebChromeClient(reactContext, webView);
193
     reactContext.addLifecycleEventListener(webView);
150
     reactContext.addLifecycleEventListener(webView);
194
     mWebViewConfig.configWebView(webView);
151
     mWebViewConfig.configWebView(webView);
195
     WebSettings settings = webView.getSettings();
152
     WebSettings settings = webView.getSettings();
454
     }
411
     }
455
   }
412
   }
456
 
413
 
414
+  @ReactProp(name = "allowsFullscreenVideo")
415
+  public void setAllowsFullscreenVideo(
416
+    WebView view,
417
+    @Nullable Boolean allowsFullscreenVideo) {
418
+    mAllowsFullscreenVideo = allowsFullscreenVideo != null && allowsFullscreenVideo;
419
+    setupWebChromeClient((ReactContext)view.getContext(), view);
420
+  }
421
+
457
   @ReactProp(name = "allowFileAccess")
422
   @ReactProp(name = "allowFileAccess")
458
   public void setAllowFileAccess(
423
   public void setAllowFileAccess(
459
     WebView view,
424
     WebView view,
554
     ((RNCWebView) webView).cleanupCallbacksAndDestroy();
519
     ((RNCWebView) webView).cleanupCallbacksAndDestroy();
555
   }
520
   }
556
 
521
 
557
-  public RNCWebViewModule getModule(ReactContext reactContext) {
522
+  public static RNCWebViewModule getModule(ReactContext reactContext) {
558
     return reactContext.getNativeModule(RNCWebViewModule.class);
523
     return reactContext.getNativeModule(RNCWebViewModule.class);
559
   }
524
   }
560
 
525
 
526
+  protected void setupWebChromeClient(ReactContext reactContext, WebView webView) {
527
+    if (mAllowsFullscreenVideo) {
528
+      mWebChromeClient = new RNCWebChromeClient(reactContext, webView) {
529
+        @Override
530
+        public void onShowCustomView(View view, CustomViewCallback callback) {
531
+          if (mVideoView != null) {
532
+            callback.onCustomViewHidden();
533
+            return;
534
+          }
535
+
536
+          mVideoView = view;
537
+          mCustomViewCallback = callback;
538
+
539
+          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
540
+            mVideoView.setSystemUiVisibility(FULLSCREEN_SYSTEM_UI_VISIBILITY);
541
+            mReactContext.getCurrentActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
542
+          }
543
+
544
+          mVideoView.setBackgroundColor(Color.BLACK);
545
+          getRootView().addView(mVideoView, FULLSCREEN_LAYOUT_PARAMS);
546
+          mWebView.setVisibility(View.GONE);
547
+
548
+          mReactContext.addLifecycleEventListener(this);
549
+        }
550
+
551
+        @Override
552
+        public void onHideCustomView() {
553
+          if (mVideoView == null) {
554
+            return;
555
+          }
556
+
557
+          mVideoView.setVisibility(View.GONE);
558
+          getRootView().removeView(mVideoView);
559
+          mCustomViewCallback.onCustomViewHidden();
560
+
561
+          mVideoView = null;
562
+          mCustomViewCallback = null;
563
+
564
+          mWebView.setVisibility(View.VISIBLE);
565
+
566
+          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
567
+            mReactContext.getCurrentActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
568
+          }
569
+
570
+          mReactContext.removeLifecycleEventListener(this);
571
+        }
572
+      };
573
+      webView.setWebChromeClient(mWebChromeClient);
574
+    } else {
575
+      if (mWebChromeClient != null) {
576
+        mWebChromeClient.onHideCustomView();
577
+      }
578
+      mWebChromeClient = new RNCWebChromeClient(reactContext, webView);
579
+      webView.setWebChromeClient(mWebChromeClient);
580
+    }
581
+  }
582
+
561
   protected static class RNCWebViewClient extends WebViewClient {
583
   protected static class RNCWebViewClient extends WebViewClient {
562
 
584
 
563
     protected boolean mLastLoadFailed = false;
585
     protected boolean mLastLoadFailed = false;
655
     }
677
     }
656
   }
678
   }
657
 
679
 
680
+  protected static class RNCWebChromeClient extends WebChromeClient implements LifecycleEventListener {
681
+    protected static final FrameLayout.LayoutParams FULLSCREEN_LAYOUT_PARAMS = new FrameLayout.LayoutParams(
682
+      LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER);
683
+
684
+    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
685
+    protected static final int FULLSCREEN_SYSTEM_UI_VISIBILITY = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
686
+      View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
687
+      View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
688
+      View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
689
+      View.SYSTEM_UI_FLAG_FULLSCREEN |
690
+      View.SYSTEM_UI_FLAG_IMMERSIVE |
691
+      View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
692
+
693
+    protected ReactContext mReactContext;
694
+    protected View mWebView;
695
+
696
+    protected View mVideoView;
697
+    protected WebChromeClient.CustomViewCallback mCustomViewCallback;
698
+
699
+    public RNCWebChromeClient(ReactContext reactContext, WebView webView) {
700
+      this.mReactContext = reactContext;
701
+      this.mWebView = webView;
702
+    }
703
+
704
+    @Override
705
+    public boolean onConsoleMessage(ConsoleMessage message) {
706
+      if (ReactBuildConfig.DEBUG) {
707
+        return super.onConsoleMessage(message);
708
+      }
709
+      // Ignore console logs in non debug builds.
710
+      return true;
711
+    }
712
+
713
+    @Override
714
+    public void onProgressChanged(WebView webView, int newProgress) {
715
+      super.onProgressChanged(webView, newProgress);
716
+      WritableMap event = Arguments.createMap();
717
+      event.putDouble("target", webView.getId());
718
+      event.putString("title", webView.getTitle());
719
+      event.putBoolean("canGoBack", webView.canGoBack());
720
+      event.putBoolean("canGoForward", webView.canGoForward());
721
+      event.putDouble("progress", (float) newProgress / 100);
722
+      dispatchEvent(
723
+        webView,
724
+        new TopLoadingProgressEvent(
725
+          webView.getId(),
726
+          event));
727
+    }
728
+
729
+    @Override
730
+    public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) {
731
+      callback.invoke(origin, true, false);
732
+    }
733
+
734
+    protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType) {
735
+      getModule(mReactContext).startPhotoPickerIntent(filePathCallback, acceptType);
736
+    }
737
+
738
+    protected void openFileChooser(ValueCallback<Uri> filePathCallback) {
739
+      getModule(mReactContext).startPhotoPickerIntent(filePathCallback, "");
740
+    }
741
+
742
+    protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType, String capture) {
743
+      getModule(mReactContext).startPhotoPickerIntent(filePathCallback, acceptType);
744
+    }
745
+
746
+    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
747
+    @Override
748
+    public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
749
+      String[] acceptTypes = fileChooserParams.getAcceptTypes();
750
+      boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE;
751
+      Intent intent = fileChooserParams.createIntent();
752
+      return getModule(mReactContext).startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple);
753
+    }
754
+
755
+    @Override
756
+    public void onHostResume() {
757
+      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && mVideoView != null && mVideoView.getSystemUiVisibility() != FULLSCREEN_SYSTEM_UI_VISIBILITY) {
758
+        mVideoView.setSystemUiVisibility(FULLSCREEN_SYSTEM_UI_VISIBILITY);
759
+      }
760
+    }
761
+
762
+    @Override
763
+    public void onHostPause() { }
764
+
765
+    @Override
766
+    public void onHostDestroy() { }
767
+
768
+    protected ViewGroup getRootView() {
769
+      return (ViewGroup) mReactContext.getCurrentActivity().findViewById(android.R.id.content);
770
+    }
771
+  }
772
+
658
   /**
773
   /**
659
    * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order
774
    * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order
660
    * to call {@link WebView#destroy} on activity destroy event and also to clear the client
775
    * to call {@link WebView#destroy} on activity destroy event and also to clear the client

+ 11
- 0
docs/Reference.md View File

30
 - [`mixedContentMode`](Reference.md#mixedcontentmode)
30
 - [`mixedContentMode`](Reference.md#mixedcontentmode)
31
 - [`thirdPartyCookiesEnabled`](Reference.md#thirdpartycookiesenabled)
31
 - [`thirdPartyCookiesEnabled`](Reference.md#thirdpartycookiesenabled)
32
 - [`userAgent`](Reference.md#useragent)
32
 - [`userAgent`](Reference.md#useragent)
33
+- [`allowsFullscreenVideo`](Reference.md#allowsfullscreenvideo)
33
 - [`allowsInlineMediaPlayback`](Reference.md#allowsinlinemediaplayback)
34
 - [`allowsInlineMediaPlayback`](Reference.md#allowsinlinemediaplayback)
34
 - [`bounces`](Reference.md#bounces)
35
 - [`bounces`](Reference.md#bounces)
35
 - [`overScrollMode`](Reference.md#overscrollmode)
36
 - [`overScrollMode`](Reference.md#overscrollmode)
591
 
592
 
592
 ---
593
 ---
593
 
594
 
595
+### `allowsFullscreenVideo`
596
+
597
+Boolean that determines whether videos are allowed to be played in fullscreen. The default value is `false`.
598
+
599
+| Type | Required | Platform |
600
+| ---- | -------- | -------- |
601
+| bool | No       | Android  |
602
+
603
+---
604
+
594
 ### `allowsInlineMediaPlayback`
605
 ### `allowsInlineMediaPlayback`
595
 
606
 
596
 Boolean that determines whether HTML5 videos play inline or use the native full-screen controller. The default value is `false`.
607
 Boolean that determines whether HTML5 videos play inline or use the native full-screen controller. The default value is `false`.

+ 1
- 0
src/WebView.android.tsx View File

48
     javaScriptEnabled: true,
48
     javaScriptEnabled: true,
49
     thirdPartyCookiesEnabled: true,
49
     thirdPartyCookiesEnabled: true,
50
     scalesPageToFit: true,
50
     scalesPageToFit: true,
51
+    allowsFullscreenVideo: false,
51
     allowFileAccess: false,
52
     allowFileAccess: false,
52
     saveFormDataDisabled: false,
53
     saveFormDataDisabled: false,
53
     cacheEnabled: true,
54
     cacheEnabled: true,