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
|
||||
|
||||
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 '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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue