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:
parent
3746665179
commit
9570d43e43
11 changed files with 288 additions and 129 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
package com.tommychow.frosty
|
package com.tommychow.frosty
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: PipCallbackHelperActivityWrapper() {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:floating/floating.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.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/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/widgets/modal.dart';
|
import 'package:frosty/widgets/modal.dart';
|
||||||
import 'package:provider/provider.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.
|
/// Creates a widget that shows the video stream (if live) and chat of the given user.
|
||||||
class VideoChat extends StatefulWidget {
|
class VideoChat extends StatefulWidget {
|
||||||
|
|
@ -133,8 +134,20 @@ class _VideoChatState extends State<VideoChat> {
|
||||||
return AnimatedOpacity(
|
return AnimatedOpacity(
|
||||||
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
|
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: ColoredBox(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(settingsStore.overlayOpacity),
|
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(
|
child: IgnorePointer(
|
||||||
ignoring: !_videoStore.overlayVisible,
|
ignoring: !_videoStore.overlayVisible,
|
||||||
child: videoOverlay,
|
child: videoOverlay,
|
||||||
|
|
@ -246,10 +259,11 @@ class _VideoChatState extends State<VideoChat> {
|
||||||
|
|
||||||
// If on Android, use PiPSwitcher to enable PiP functionality.
|
// If on Android, use PiPSwitcher to enable PiP functionality.
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
return PiPSwitcher(
|
return PipWidget(
|
||||||
floating: _videoStore.floating,
|
pipLayout: PipActionsLayout.media_only_pause,
|
||||||
childWhenEnabled: player,
|
onPipAction: (_) => _videoStore.handlePausePlay(),
|
||||||
childWhenDisabled: videoChat,
|
pipChild: player,
|
||||||
|
child: videoChat,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_store.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';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
/// Creates a [WebView] widget that shows a channel's video stream.
|
/// Creates a [WebView] widget that shows a channel's video stream.
|
||||||
class Video extends StatelessWidget {
|
class Video extends StatefulWidget {
|
||||||
final VideoStore videoStore;
|
final VideoStore videoStore;
|
||||||
|
|
||||||
const Video({
|
const Video({
|
||||||
|
|
@ -11,18 +14,42 @@ class Video extends StatelessWidget {
|
||||||
required this.videoStore,
|
required this.videoStore,
|
||||||
}) : super(key: key);
|
}) : 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return WebView(
|
return WebView(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
initialUrl: videoStore.videoUrl,
|
initialUrl: widget.videoStore.videoUrl,
|
||||||
javascriptMode: JavascriptMode.unrestricted,
|
javascriptMode: JavascriptMode.unrestricted,
|
||||||
allowsInlineMediaPlayback: true,
|
allowsInlineMediaPlayback: true,
|
||||||
initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow,
|
initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow,
|
||||||
onWebViewCreated: (controller) => videoStore.controller = controller,
|
onWebViewCreated: (controller) => widget.videoStore.controller = controller,
|
||||||
onPageFinished: (string) => videoStore.initVideo(),
|
onPageFinished: (string) => widget.videoStore.initVideo(),
|
||||||
navigationDelegate: videoStore.handleNavigation,
|
navigationDelegate: widget.videoStore.handleNavigation,
|
||||||
javascriptChannels: videoStore.javascriptChannels,
|
javascriptChannels: widget.videoStore.javascriptChannels,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/screens/channel/video/video_store.dart';
|
||||||
import 'package:frosty/widgets/button.dart';
|
import 'package:frosty/widgets/button.dart';
|
||||||
import 'package:frosty/widgets/dialog.dart';
|
import 'package:frosty/widgets/dialog.dart';
|
||||||
import 'package:frosty/widgets/profile_picture.dart';
|
import 'package:frosty/widgets/profile_picture.dart';
|
||||||
|
import 'package:frosty/widgets/uptime.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
/// 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) {
|
Widget build(BuildContext context) {
|
||||||
final orientation = MediaQuery.of(context).orientation;
|
final orientation = MediaQuery.of(context).orientation;
|
||||||
|
|
||||||
final backButton = Align(
|
final backButton = IconButton(
|
||||||
alignment: Alignment.topLeft,
|
tooltip: 'Back',
|
||||||
child: IconButton(
|
icon: Icon(
|
||||||
tooltip: 'Back',
|
Icons.adaptive.arrow_back,
|
||||||
icon: Icon(
|
color: Colors.white,
|
||||||
Icons.adaptive.arrow_back,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
),
|
),
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
);
|
);
|
||||||
|
|
||||||
final settingsButton = IconButton(
|
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 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(
|
return Observer(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|
@ -248,82 +225,151 @@ class VideoOverlay extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
backButton,
|
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,
|
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
|
||||||
sleepTimerButton,
|
sleepTimerButton,
|
||||||
settingsButton,
|
settingsButton,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Center(
|
||||||
// Add a play button when paused for Android
|
child: IconButton(
|
||||||
// When an ad is paused on Android there is no way to unpause, so a play button is necessary.
|
tooltip: videoStore.paused ? 'Play' : 'Pause',
|
||||||
if (Platform.isAndroid)
|
iconSize: 50.0,
|
||||||
Center(
|
icon: videoStore.paused
|
||||||
child: IconButton(
|
? const Icon(
|
||||||
tooltip: videoStore.paused ? 'Play' : 'Pause',
|
Icons.play_arrow,
|
||||||
iconSize: 50.0,
|
color: Colors.white,
|
||||||
icon: videoStore.paused
|
)
|
||||||
? const Icon(
|
: const Icon(
|
||||||
Icons.play_arrow,
|
Icons.pause,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
)
|
),
|
||||||
: const Icon(
|
onPressed: videoStore.handlePausePlay,
|
||||||
Icons.pause,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
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(
|
Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: Padding(
|
||||||
onTap: videoStore.handleExpand,
|
padding: const EdgeInsets.only(bottom: 8.0, left: 8.0),
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(10.0),
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
child: videoStore.settingsStore.expandInfo
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
const Icon(
|
||||||
streamer,
|
Icons.games,
|
||||||
const SizedBox(height: 5.0),
|
color: Colors.white,
|
||||||
Tooltip(
|
size: 14,
|
||||||
message: videoStore.streamInfo!.title.trim(),
|
),
|
||||||
preferBelow: false,
|
const SizedBox(width: 5),
|
||||||
padding: const EdgeInsets.all(10.0),
|
Flexible(
|
||||||
child: Text(
|
child: Tooltip(
|
||||||
videoStore.streamInfo!.title.trim(),
|
message: category,
|
||||||
maxLines: orientation == Orientation.portrait ? 1 : 5,
|
preferBelow: false,
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: const TextStyle(color: Colors.white),
|
category,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5.0),
|
),
|
||||||
Text(
|
),
|
||||||
'${videoStore.streamInfo!.gameName.isNotEmpty ? videoStore.streamInfo?.gameName : 'No Category'} \u2022 ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers',
|
],
|
||||||
|
),
|
||||||
|
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(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
: streamer,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:floating/floating.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:frosty/apis/twitch_api.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/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
import 'package:simple_pip_mode/simple_pip.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
part 'video_store.g.dart';
|
part 'video_store.g.dart';
|
||||||
|
|
@ -26,8 +26,8 @@ abstract class VideoStoreBase with Store {
|
||||||
|
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
/// The [Floating] instance used for initiating PiP on Android.
|
/// The [SimplePip] instance used for initiating PiP on Android.
|
||||||
final floating = Floating();
|
final pip = SimplePip();
|
||||||
|
|
||||||
/// The webview controller used for injecting JavaScript to control the webview and video player.
|
/// The webview controller used for injecting JavaScript to control the webview and video player.
|
||||||
WebViewController? controller;
|
WebViewController? controller;
|
||||||
|
|
@ -45,12 +45,16 @@ abstract class VideoStoreBase with Store {
|
||||||
late final javascriptChannels = {
|
late final javascriptChannels = {
|
||||||
JavascriptChannel(
|
JavascriptChannel(
|
||||||
name: 'VideoPause',
|
name: 'VideoPause',
|
||||||
onMessageReceived: (message) => _paused = true,
|
onMessageReceived: (message) {
|
||||||
|
_paused = true;
|
||||||
|
if (Platform.isAndroid) pip.setIsPlaying(false);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
JavascriptChannel(
|
JavascriptChannel(
|
||||||
name: 'VideoPlaying',
|
name: 'VideoPlaying',
|
||||||
onMessageReceived: (message) {
|
onMessageReceived: (message) {
|
||||||
_paused = false;
|
_paused = false;
|
||||||
|
if (Platform.isAndroid) pip.setIsPlaying(true);
|
||||||
controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;');
|
controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;');
|
||||||
controller?.runJavascript('document.getElementsByTagName("video")[0].volume = 1.0;');
|
controller?.runJavascript('document.getElementsByTagName("video")[0].volume = 1.0;');
|
||||||
},
|
},
|
||||||
|
|
@ -107,6 +111,13 @@ abstract class VideoStoreBase with Store {
|
||||||
required this.authStore,
|
required this.authStore,
|
||||||
required this.settingsStore,
|
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.
|
// Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds.
|
||||||
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
|
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
|
||||||
|
|
||||||
|
|
@ -269,12 +280,12 @@ abstract class VideoStoreBase with Store {
|
||||||
|
|
||||||
/// Initiate picture in picture if available.
|
/// 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 Android, this will utilize the native Android PiP API.
|
||||||
|
/// On iOS, this will utilize the web picture-in-picture API.
|
||||||
void requestPictureInPicture() {
|
void requestPictureInPicture() {
|
||||||
try {
|
try {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
floating.enable();
|
pip.enterPipMode(autoEnter: true);
|
||||||
} else if (Platform.isIOS) {
|
} else if (Platform.isIOS) {
|
||||||
controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();');
|
controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();');
|
||||||
}
|
}
|
||||||
|
|
@ -285,12 +296,14 @@ abstract class VideoStoreBase with Store {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void dispose() {
|
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.
|
// 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.
|
// Will both prevent the video from continuing to play when dismissed and closes PiP on iOS.
|
||||||
if (Platform.isIOS) controller?.reload();
|
if (Platform.isIOS) controller?.reload();
|
||||||
|
|
||||||
_disposeOverlayReaction();
|
_disposeOverlayReaction();
|
||||||
floating.dispose();
|
|
||||||
sleepTimer?.cancel();
|
sleepTimer?.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:frosty/widgets/animate_scale.dart';
|
||||||
import 'package:frosty/widgets/block_report_modal.dart';
|
import 'package:frosty/widgets/block_report_modal.dart';
|
||||||
import 'package:frosty/widgets/loading_indicator.dart';
|
import 'package:frosty/widgets/loading_indicator.dart';
|
||||||
import 'package:frosty/widgets/profile_picture.dart';
|
import 'package:frosty/widgets/profile_picture.dart';
|
||||||
|
import 'package:frosty/widgets/uptime.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
|
@ -87,8 +88,8 @@ class StreamCard extends StatelessWidget {
|
||||||
if (large)
|
if (large)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(10.0),
|
padding: const EdgeInsets.all(10.0),
|
||||||
child: Text(
|
child: Uptime(
|
||||||
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
|
startTime: streamInfo.startedAt,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.0,
|
fontSize: 16.0,
|
||||||
color: Colors.white.withOpacity(0.8),
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
|
@ -97,11 +98,21 @@ class StreamCard extends StatelessWidget {
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(
|
Container(
|
||||||
color: const Color.fromRGBO(0, 0, 0, 0.5),
|
padding: const EdgeInsets.all(2.0),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2.0),
|
decoration: const BoxDecoration(
|
||||||
child: Text(
|
color: Color.fromRGBO(0, 0, 0, 0.5),
|
||||||
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
|
borderRadius: BorderRadius.all(
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
static const defaultShowVideo = true;
|
static const defaultShowVideo = true;
|
||||||
static const defaultShowOverlay = true;
|
static const defaultShowOverlay = true;
|
||||||
static const defaultToggleableOverlay = false;
|
static const defaultToggleableOverlay = false;
|
||||||
static const defaultOverlayOpacity = 0.5;
|
static const defaultOverlayOpacity = 0.8;
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultShowVideo)
|
@JsonKey(defaultValue: defaultShowVideo)
|
||||||
@observable
|
@observable
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
|
||||||
..showVideo = json['showVideo'] as bool? ?? true
|
..showVideo = json['showVideo'] as bool? ?? true
|
||||||
..showOverlay = json['showOverlay'] as bool? ?? true
|
..showOverlay = json['showOverlay'] as bool? ?? true
|
||||||
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
..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
|
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
||||||
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true
|
..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true
|
||||||
..autocomplete = json['autocomplete'] as bool? ?? true
|
..autocomplete = json['autocomplete'] as bool? ?? true
|
||||||
|
|
|
||||||
44
lib/widgets/uptime.dart
Normal file
44
lib/widgets/uptime.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -281,13 +281,6 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
floating:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: floating
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.0"
|
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -861,6 +854,15 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,11 @@ dependencies:
|
||||||
collection: ^1.16.0
|
collection: ^1.16.0
|
||||||
flutter_cache_manager: ^3.3.0
|
flutter_cache_manager: ^3.3.0
|
||||||
device_info_plus: ^4.0.0
|
device_info_plus: ^4.0.0
|
||||||
floating: ^1.1.0
|
|
||||||
wakelock: ^0.6.2
|
wakelock: ^0.6.2
|
||||||
device_preview: ^1.1.0
|
device_preview: ^1.1.0
|
||||||
|
simple_pip_mode:
|
||||||
|
git:
|
||||||
|
url: https://github.com/tommyxchow/simple_pip_mode_flutter
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue