Add auto PiP on Android and redesign stream overlay (#218)

* Improve PiP behavior (Android)
Added the ability to enter PiP mode when leaving the app on Android.

* Add automatic PiP option

* Redesign video overlay

* Update stream card uptime counter

* Use gradient for overlay opacity

* Restore play button when paused on iOS

* Restore refresh button on overlay

* Prefer above for category tooltip

* Increase default overlay opacity to 80%

* Improve pip behavior

* Fix uptime setState after dispose

* Remove automatic pip option
Redundant since you can disable it in system settings

* Check if `setAutoEnterEnabled` is available

* Fix missing pip auto enter

* Disable auto pip when leaving channel

* Fix pip exception on iOS
This commit is contained in:
Tommy Chow 2022-12-27 23:47:55 -05:00 • committed by GitHub
parent 3746665179
commit 9570d43e43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 288 additions and 129 deletions

View file

@ -1,6 +1,6 @@
package com.tommychow.frosty
import io.flutter.embedding.android.FlutterActivity
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
class MainActivity: FlutterActivity() {
class MainActivity: PipCallbackHelperActivityWrapper() {
}

View file

@ -1,6 +1,5 @@
import 'dart:io';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -22,6 +21,8 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget {
@ -133,8 +134,20 @@ class _VideoChatState extends State<VideoChat> {
return AnimatedOpacity(
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ColoredBox(
color: Colors.black.withOpacity(settingsStore.overlayOpacity),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.25, 0.75, 1.0],
colors: [
Colors.black.withOpacity(settingsStore.overlayOpacity),
Colors.transparent,
Colors.transparent,
Colors.black.withOpacity(settingsStore.overlayOpacity),
],
),
),
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
child: videoOverlay,
@ -246,10 +259,11 @@ class _VideoChatState extends State<VideoChat> {
// If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) {
return PiPSwitcher(
floating: _videoStore.floating,
childWhenEnabled: player,
childWhenDisabled: videoChat,
return PipWidget(
pipLayout: PipActionsLayout.media_only_pause,
onPipAction: (_) => _videoStore.handlePausePlay(),
pipChild: player,
child: videoChat,
);
}

View file

@ -1,9 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:simple_pip_mode/simple_pip.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Creates a [WebView] widget that shows a channel's video stream.
class Video extends StatelessWidget {
class Video extends StatefulWidget {
final VideoStore videoStore;
const Video({
@ -11,18 +14,42 @@ class Video extends StatelessWidget {
required this.videoStore,
}) : super(key: key);
@override
State<Video> createState() => _VideoState();
}
class _VideoState extends State<Video> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState lifecycleState) async {
if (Platform.isAndroid && !await SimplePip.isAutoPipAvailable && lifecycleState == AppLifecycleState.inactive) {
widget.videoStore.requestPictureInPicture();
}
}
@override
Widget build(BuildContext context) {
return WebView(
backgroundColor: Colors.black,
initialUrl: videoStore.videoUrl,
initialUrl: widget.videoStore.videoUrl,
javascriptMode: JavascriptMode.unrestricted,
allowsInlineMediaPlayback: true,
initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow,
onWebViewCreated: (controller) => videoStore.controller = controller,
onPageFinished: (string) => videoStore.initVideo(),
navigationDelegate: videoStore.handleNavigation,
javascriptChannels: videoStore.javascriptChannels,
onWebViewCreated: (controller) => widget.videoStore.controller = controller,
onPageFinished: (string) => widget.videoStore.initVideo(),
navigationDelegate: widget.videoStore.handleNavigation,
javascriptChannels: widget.videoStore.javascriptChannels,
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

View file

@ -9,6 +9,7 @@ import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
@ -96,16 +97,13 @@ class VideoOverlay extends StatelessWidget {
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
final backButton = Align(
alignment: Alignment.topLeft,
child: IconButton(
final backButton = IconButton(
tooltip: 'Back',
icon: Icon(
Icons.adaptive.arrow_back,
color: Colors.white,
),
onPressed: Navigator.of(context).pop,
),
);
final settingsButton = IconButton(
@ -214,31 +212,10 @@ class VideoOverlay extends StatelessWidget {
);
}
final streamTitle = videoStore.streamInfo!.title.trim();
final category = videoStore.streamInfo!.gameName.isNotEmpty ? videoStore.streamInfo!.gameName : 'No Category';
final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})';
final streamer = Row(
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 10.0,
),
const SizedBox(width: 5.0),
Flexible(
child: Tooltip(
message: streamerName,
preferBelow: false,
child: Text(
streamerName,
style: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
);
return Observer(
builder: (context) {
@ -248,16 +225,58 @@ class VideoOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 10,
),
const SizedBox(width: 5),
Flexible(
child: Tooltip(
message: streamerName,
child: Text(
streamerName,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 5.0),
Tooltip(
message: streamTitle,
child: Text(
streamTitle,
maxLines: orientation == Orientation.portrait ? 1 : 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
sleepTimerButton,
settingsButton,
],
),
// Add a play button when paused for Android
// When an ad is paused on Android there is no way to unpause, so a play button is necessary.
if (Platform.isAndroid)
Center(
child: IconButton(
tooltip: videoStore.paused ? 'Play' : 'Pause',
@ -273,18 +292,6 @@ class VideoOverlay extends StatelessWidget {
),
onPressed: videoStore.handlePausePlay,
),
)
else if (!videoStore.paused)
Center(
child: IconButton(
tooltip: 'Pause',
iconSize: 50.0,
icon: const Icon(
Icons.pause,
color: Colors.white,
),
onPressed: videoStore.handlePausePlay,
),
),
Align(
alignment: Alignment.bottomLeft,
@ -292,38 +299,77 @@ class VideoOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: GestureDetector(
onTap: videoStore.handleExpand,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: videoStore.settingsStore.expandInfo
? Column(
padding: const EdgeInsets.only(bottom: 8.0, left: 8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
streamer,
const SizedBox(height: 5.0),
Tooltip(
message: videoStore.streamInfo!.title.trim(),
preferBelow: false,
padding: const EdgeInsets.all(10.0),
child: Text(
videoStore.streamInfo!.title.trim(),
maxLines: orientation == Orientation.portrait ? 1 : 5,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(height: 5.0),
Text(
'${videoStore.streamInfo!.gameName.isNotEmpty ? videoStore.streamInfo?.gameName : 'No Category'} \u2022 ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers',
style: const TextStyle(
Row(
children: [
const Icon(
Icons.games,
color: Colors.white,
size: 14,
),
const SizedBox(width: 5),
Flexible(
child: Tooltip(
message: category,
preferBelow: false,
child: Text(
category,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
)
: streamer,
),
const SizedBox(height: 5),
Row(
children: [
Row(
children: [
const Icon(
Icons.circle,
color: Colors.red,
size: 14,
),
const SizedBox(width: 5),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(width: 10),
Row(
children: [
const Icon(
Icons.people,
color: Colors.white,
size: 14,
),
const SizedBox(width: 5),
Text(
NumberFormat().format(videoStore.streamInfo?.viewerCount),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
],
),
),
),

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:floating/floating.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
@ -10,6 +9,7 @@ import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
import 'package:simple_pip_mode/simple_pip.dart';
import 'package:webview_flutter/webview_flutter.dart';
part 'video_store.g.dart';
@ -26,8 +26,8 @@ abstract class VideoStoreBase with Store {
final SettingsStore settingsStore;
/// The [Floating] instance used for initiating PiP on Android.
final floating = Floating();
/// The [SimplePip] instance used for initiating PiP on Android.
final pip = SimplePip();
/// The webview controller used for injecting JavaScript to control the webview and video player.
WebViewController? controller;
@ -45,12 +45,16 @@ abstract class VideoStoreBase with Store {
late final javascriptChannels = {
JavascriptChannel(
name: 'VideoPause',
onMessageReceived: (message) => _paused = true,
onMessageReceived: (message) {
_paused = true;
if (Platform.isAndroid) pip.setIsPlaying(false);
},
),
JavascriptChannel(
name: 'VideoPlaying',
onMessageReceived: (message) {
_paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true);
controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;');
controller?.runJavascript('document.getElementsByTagName("video")[0].volume = 1.0;');
},
@ -107,6 +111,13 @@ abstract class VideoStoreBase with Store {
required this.authStore,
required this.settingsStore,
}) {
// On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it.
if (Platform.isAndroid) {
SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) {
if (isAutoPipAvailable) pip.setAutoPipMode();
});
}
// Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds.
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
@ -269,12 +280,12 @@ abstract class VideoStoreBase with Store {
/// Initiate picture in picture if available.
///
/// On iOS, this will utilize the web picture-in-picture API.
/// On Android, this will utilize the native Android PiP API.
/// On iOS, this will utilize the web picture-in-picture API.
void requestPictureInPicture() {
try {
if (Platform.isAndroid) {
floating.enable();
pip.enterPipMode(autoEnter: true);
} else if (Platform.isIOS) {
controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();');
}
@ -285,12 +296,14 @@ abstract class VideoStoreBase with Store {
@action
void dispose() {
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
if (Platform.isAndroid) pip.setAutoPipMode(autoEnter: false);
// Not ideal, but seems like the only way of disposing of the video properly.
// Will both prevent the video from continuing to play when dismissed and closes PiP on iOS.
if (Platform.isIOS) controller?.reload();
_disposeOverlayReaction();
floating.dispose();
sleepTimer?.cancel();
}
}

View file

@ -12,6 +12,7 @@ import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -87,8 +88,8 @@ class StreamCard extends StatelessWidget {
if (large)
Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
child: Uptime(
startTime: streamInfo.startedAt,
style: TextStyle(
fontSize: 16.0,
color: Colors.white.withOpacity(0.8),
@ -97,11 +98,21 @@ class StreamCard extends StatelessWidget {
)
else
Container(
color: const Color.fromRGBO(0, 0, 0, 0.5),
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
style: const TextStyle(fontSize: 12, color: Colors.white),
padding: const EdgeInsets.all(2.0),
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.5),
borderRadius: BorderRadius.all(
Radius.circular(3.0),
),
),
margin: const EdgeInsets.all(2.0),
child: Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
)
],

View file

@ -52,7 +52,7 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowVideo = true;
static const defaultShowOverlay = true;
static const defaultToggleableOverlay = false;
static const defaultOverlayOpacity = 0.5;
static const defaultOverlayOpacity = 0.8;
@JsonKey(defaultValue: defaultShowVideo)
@observable

View file

@ -18,7 +18,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..showVideo = json['showVideo'] as bool? ?? true
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5
..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.8
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true
..autocomplete = json['autocomplete'] as bool? ?? true

44
lib/widgets/uptime.dart Normal file
View file

@ -0,0 +1,44 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
/// A widget that displays a timer representing the time since the given start time.
/// The timer is updated every second.
class Uptime extends StatefulWidget {
final String startTime;
final TextStyle? style;
const Uptime({
Key? key,
required this.startTime,
this.style,
}) : super(key: key);
@override
State<Uptime> createState() => _UptimeState();
}
class _UptimeState extends State<Uptime> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) => setState(() {}));
}
@override
Widget build(BuildContext context) {
return Text(
DateTime.now().difference(DateTime.parse(widget.startTime)).toString().split('.')[0],
style: widget.style?.copyWith(fontFeatures: [const FontFeature.tabularFigures()]),
);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}

View file

@ -281,13 +281,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
floating:
dependency: "direct main"
description:
name: floating
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
flutter:
dependency: "direct main"
description: flutter
@ -861,6 +854,15 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
simple_pip_mode:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "1acaf4fc25cc87dbc5ce07f341ad4fd2ff086f4f"
url: "https://github.com/tommyxchow/simple_pip_mode_flutter"
source: git
version: "0.8.0"
sky_engine:
dependency: transitive
description: flutter

View file

@ -54,9 +54,11 @@ dependencies:
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^4.0.0
floating: ^1.1.0
wakelock: ^0.6.2
device_preview: ^1.1.0
simple_pip_mode:
git:
url: https://github.com/tommyxchow/simple_pip_mode_flutter
dev_dependencies:
flutter_test: