Merge pull request #145 from tommyxchow/dev

Release v1.5.0
This commit is contained in:
Tommy Chow 2022-06-11 21:30:45 -04:00 • committed by GitHub
commit 2058b7da42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 872 additions and 615 deletions

View file

@ -14,8 +14,8 @@ jobs:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v1
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
- run: flutter doctor -v
- run: flutter pub get

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

View file

@ -1,4 +1,6 @@
PODS:
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_native_splash (0.0.1):
- Flutter
@ -31,6 +33,7 @@ PODS:
- Flutter
DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
@ -49,6 +52,8 @@ SPEC REPOS:
- Sentry
EXTERNAL SOURCES:
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_native_splash:
@ -73,6 +78,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

View file

@ -5,48 +5,15 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "LaunchImageDark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -18,7 +18,6 @@
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="BrandingImage" translatesAutoresizingMaskIntoConstraints="NO" id="Uyq-Kz-ftE"/>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
@ -41,6 +40,5 @@
<resources>
<image name="LaunchImage" width="757" height="358"/>
<image name="LaunchBackground" width="1" height="1"/>
<image name="BrandingImage" width="1" height="1"/>
</resources>
</document>

View file

@ -75,86 +75,86 @@ void main() async {
// Navigator key for sleep timer. Allows navigation popping without context.
final navigatorKey = GlobalKey<NavigatorState>();
const gray = Color.fromRGBO(22, 22, 22, 1.0);
const purple = Color(0xff9146ff);
final lightTheme = ThemeData(
canvasColor: Colors.white,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: Colors.white,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black,
),
iconTheme: IconThemeData(color: Colors.black),
),
colorScheme: ColorScheme.fromSwatch(
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
toggleableActiveColor: purple,
tabBarTheme: const TabBarTheme(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
),
);
final darkTheme = ThemeData(
canvasColor: gray,
brightness: Brightness.dark,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: gray,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(backgroundColor: gray),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
dialogBackgroundColor: gray,
toggleableActiveColor: purple,
);
final oledTheme = ThemeData(
canvasColor: Colors.black,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: Colors.black,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
dialogBackgroundColor: Colors.black,
toggleableActiveColor: purple,
);
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const gray = Color.fromRGBO(22, 22, 22, 1.0);
const purple = Color(0xff9146ff);
final lightTheme = ThemeData(
canvasColor: Colors.white,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: Colors.white,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black,
),
iconTheme: IconThemeData(color: Colors.black),
),
colorScheme: ColorScheme.fromSwatch(
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
toggleableActiveColor: purple,
tabBarTheme: const TabBarTheme(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
),
);
final darkTheme = ThemeData(
canvasColor: gray,
brightness: Brightness.dark,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: gray,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(backgroundColor: gray),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
dialogBackgroundColor: gray,
toggleableActiveColor: purple,
);
final oledTheme = ThemeData(
canvasColor: Colors.black,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: Colors.black,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
dialogBackgroundColor: Colors.black,
toggleableActiveColor: purple,
);
return Observer(
builder: (context) {
final settingsStore = context.read<SettingsStore>();

View file

@ -108,8 +108,7 @@ class Chat extends StatelessWidget {
),
),
),
if (chatStore.settings.showBottomBar && (!chatStore.settings.fullScreen || MediaQuery.of(context).orientation == Orientation.portrait))
ChatBottomBar(chatStore: chatStore),
if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore),
if (chatStore.assetsStore.showEmoteMenu)
SizedBox(
height: MediaQuery.of(context).size.height / 3,

View file

@ -43,6 +43,8 @@ class _ChattersListState extends State<ChattersList> {
widget.chatDetails.showJumpButton = true;
}
});
widget.chatDetails.updateChatters(widget.userLogin);
}
@override
@ -58,7 +60,6 @@ class _ChattersListState extends State<ChattersList> {
];
final chatDetailsStore = widget.chatDetails;
chatDetailsStore.updateChatters(widget.userLogin);
return Column(
children: [
@ -116,6 +117,7 @@ class _ChattersListState extends State<ChattersList> {
}
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: _scrollController,
slivers: [
SliverPadding(

View file

@ -14,16 +14,20 @@ class ChatBottomBar extends StatelessWidget {
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final emotes = [
final matchingEmotes = [
...chatStore.assetsStore.userEmoteToObject.values,
...chatStore.assetsStore.bttvEmotes,
...chatStore.assetsStore.ffzEmotes,
...chatStore.assetsStore.sevenTVEmotes
].where((emote) => emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase())).toList();
final matchingChatters = chatStore.chatDetailsStore.allChatters
.where((chatter) => chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase()))
.toList();
return Column(
children: [
if (chatStore.settings.emoteAutocomplete && chatStore.showAutocomplete && emotes.isNotEmpty) ...[
if (chatStore.settings.autocomplete && chatStore.showEmoteAutocomplete && matchingEmotes.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
@ -32,27 +36,52 @@ class ChatBottomBar extends StatelessWidget {
height: 50,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
itemCount: emotes.length,
itemCount: matchingEmotes.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index], autocompleteMode: true),
onTap: () => chatStore.addEmote(matchingEmotes[index], autocompleteMode: true),
child: Tooltip(
message: emotes[index].name,
message: matchingEmotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
child: Center(
child: CachedNetworkImage(
imageUrl: emotes[index].url,
imageUrl: matchingEmotes[index].url,
fadeInDuration: const Duration(),
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: emotes[index].width?.toDouble(),
height: matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize,
width: matchingEmotes[index].width?.toDouble(),
),
),
),
),
),
),
),
],
if (chatStore.settings.autocomplete && chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
),
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
itemCount: matchingChatters.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => TextButton(
onPressed: () {
final split = chatStore.textController.text.split(' ')
..removeLast()
..add('@${matchingChatters[index]} ');
chatStore.textController.text = split.join(' ');
chatStore.textController.selection = TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length));
},
child: Text(matchingChatters[index]),
),
),
)
],
Row(
@ -73,9 +102,7 @@ class ChatBottomBar extends StatelessWidget {
),
),
),
if (!chatStore.expandChat &&
chatStore.settings.landscapeChatWidth < 0.3 &&
MediaQuery.of(context).orientation == Orientation.landscape)
if (!chatStore.expandChat && chatStore.settings.chatWidth < 0.3 && MediaQuery.of(context).orientation == Orientation.landscape)
IconButton(
tooltip: 'Send a Message',
onPressed: () {
@ -115,7 +142,7 @@ class ChatBottomBar extends StatelessWidget {
),
),
),
if (chatStore.settings.landscapeChatWidth > 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait)
if (chatStore.settings.chatWidth > 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait)
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send),

View file

@ -23,6 +23,10 @@ abstract class ChatDetailsStoreBase with Store {
@observable
var filterText = '';
/// The list and types of chatters in the chat room.
@readonly
ChatUsers? _chatUsers;
@computed
Iterable<List<String>> get filteredUsers => [
_chatUsers!.chatters.broadcaster,
@ -34,9 +38,16 @@ abstract class ChatDetailsStoreBase with Store {
_chatUsers!.chatters.viewers,
].map((e) => e.where((user) => user.contains(filterText)).toList());
/// The list and types of chatters in the chat room.
@readonly
ChatUsers? _chatUsers;
@computed
List<String> get allChatters => [
...?_chatUsers?.chatters.broadcaster,
...?_chatUsers?.chatters.staff,
...?_chatUsers?.chatters.admins,
...?_chatUsers?.chatters.globalMods,
...?_chatUsers?.chatters.moderators,
...?_chatUsers?.chatters.vips,
...?_chatUsers?.chatters.viewers,
];
@readonly
String? _error;

View file

@ -16,6 +16,13 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
Computed<Iterable<List<String>>>(() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers'))
.value;
Computed<List<String>>? _$allChattersComputed;
@override
List<String> get allChatters =>
(_$allChattersComputed ??= Computed<List<String>>(() => super.allChatters,
name: 'ChatDetailsStoreBase.allChatters'))
.value;
late final _$roomStateAtom =
Atom(name: 'ChatDetailsStoreBase.roomState', context: context);
@ -116,7 +123,8 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
roomState: ${roomState},
showJumpButton: ${showJumpButton},
filterText: ${filterText},
filteredUsers: ${filteredUsers}
filteredUsers: ${filteredUsers},
allChatters: ${allChatters}
''';
}
}

View file

@ -72,7 +72,10 @@ abstract class ChatStoreBase with Store {
var _autoScroll = true;
@readonly
var _showAutocomplete = false;
var _showEmoteAutocomplete = false;
@readonly
var _showMentionAutocomplete = false;
/// The logged-in user's appearance in chat.
@readonly
@ -100,6 +103,7 @@ abstract class ChatStoreBase with Store {
));
assetsStore.init();
chatDetailsStore.updateChatters(channelName);
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
@ -121,17 +125,25 @@ abstract class ChatStoreBase with Store {
}
});
// Add a listener to the textfield focus that will hide the emote menu if it is currently shown.
textFieldFocusNode.addListener(() {
if (textFieldFocusNode.hasFocus && assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false;
if (textFieldFocusNode.hasFocus) {
// Hide the emote menu if it is currently shown.
if (assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false;
// Refresh the chatters list to keep autocomplete mentions updated.
if (settings.autocomplete) chatDetailsStore.updateChatters(channelName);
}
// Un-expand the chat when unfocusing.
if (!textFieldFocusNode.hasFocus) expandChat = false;
});
// Add a listener to the textfield that will show/hide the autocomplete bar if focused.
// Will also rebuild the autocomplete bar when typing, refreshing the results as the user types.
textController
.addListener(() => _showAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty ? true : false);
textController.addListener(
() => _showEmoteAutocomplete = !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty);
textController.addListener(() => _showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@'));
}
/// Handle and process the provided string-representation of the IRC data.
@ -342,7 +354,7 @@ abstract class ChatStoreBase with Store {
void addEmote(Emote emote, {bool autocompleteMode = false}) {
if (textController.text.isEmpty || textController.text.endsWith(' ')) {
textController.text += '${emote.name} ';
} else if (autocompleteMode && _showAutocomplete && textController.text.endsWith('')) {
} else if (autocompleteMode && _showEmoteAutocomplete && textController.text.endsWith('')) {
final split = textController.text.split(' ')
..removeLast()
..add('${emote.name} ');

View file

@ -45,21 +45,41 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_showAutocompleteAtom =
Atom(name: 'ChatStoreBase._showAutocomplete', context: context);
late final _$_showEmoteAutocompleteAtom =
Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context);
bool get showAutocomplete {
_$_showAutocompleteAtom.reportRead();
return super._showAutocomplete;
bool get showEmoteAutocomplete {
_$_showEmoteAutocompleteAtom.reportRead();
return super._showEmoteAutocomplete;
}
@override
bool get _showAutocomplete => showAutocomplete;
bool get _showEmoteAutocomplete => showEmoteAutocomplete;
@override
set _showAutocomplete(bool value) {
_$_showAutocompleteAtom.reportWrite(value, super._showAutocomplete, () {
super._showAutocomplete = value;
set _showEmoteAutocomplete(bool value) {
_$_showEmoteAutocompleteAtom
.reportWrite(value, super._showEmoteAutocomplete, () {
super._showEmoteAutocomplete = value;
});
}
late final _$_showMentionAutocompleteAtom =
Atom(name: 'ChatStoreBase._showMentionAutocomplete', context: context);
bool get showMentionAutocomplete {
_$_showMentionAutocompleteAtom.reportRead();
return super._showMentionAutocomplete;
}
@override
bool get _showMentionAutocomplete => showMentionAutocomplete;
@override
set _showMentionAutocomplete(bool value) {
_$_showMentionAutocompleteAtom
.reportWrite(value, super._showMentionAutocomplete, () {
super._showMentionAutocomplete = value;
});
}

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:frosty/api/twitch_api.dart';
@ -37,6 +39,9 @@ abstract class VideoStoreBase with Store {
@readonly
var _overlayVisible = true;
@readonly
var _isIPad = false;
@readonly
StreamTwitch? _streamInfo;
@ -152,13 +157,27 @@ abstract class VideoStoreBase with Store {
return NavigationDecision.prevent;
}
void initVideo() {
@action
Future<void> initVideo() async {
try {
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => Pause.postMessage("video paused"));');
controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("play", () => Play.postMessage("video playing"));');
} catch (e) {
debugPrint(e.toString());
}
// Determine whether the device is an iPad or not.
// Used to show or hide the rotate button on the overlay.
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
if (Platform.isIOS) {
final deviceInfo = DeviceInfoPlugin();
final info = await deviceInfo.iosInfo;
if (info.model?.toLowerCase().contains('ipad') == true) {
_isIPad = true;
} else {
_isIPad = false;
}
}
}
void requestPictureInPicture() {

View file

@ -101,6 +101,24 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_isIPadAtom =
Atom(name: 'VideoStoreBase._isIPad', context: context);
bool get isIPad {
_$_isIPadAtom.reportRead();
return super._isIPad;
}
@override
bool get _isIPad => isIPad;
@override
set _isIPad(bool value) {
_$_isIPadAtom.reportWrite(value, super._isIPad, () {
super._isIPad = value;
});
}
late final _$_streamInfoAtom =
Atom(name: 'VideoStoreBase._streamInfo', context: context);
@ -136,6 +154,14 @@ mixin _$VideoStore on VideoStoreBase, Store {
.run(() => super.handleToggleOverlay());
}
late final _$initVideoAsyncAction =
AsyncAction('VideoStoreBase.initVideo', context: context);
@override
Future<void> initVideo() {
return _$initVideoAsyncAction.run(() => super.initVideo());
}
late final _$VideoStoreBaseActionController =
ActionController(name: 'VideoStoreBase', context: context);

View file

@ -1,27 +0,0 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/channel/stores/video_store.dart';
import 'package:webview_flutter/webview_flutter.dart';
class Video extends StatelessWidget {
final VideoStore videoStore;
const Video({
Key? key,
required this.videoStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return WebView(
backgroundColor: Colors.black,
initialUrl: 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,
);
}
}

View file

@ -1,321 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/channel/stores/video_store.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:intl/intl.dart';
class VideoOverlay extends StatelessWidget {
final VideoStore videoStore;
const VideoOverlay({Key? key, required this.videoStore}) : super(key: key);
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sleep Timer'),
content: Observer(
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
DropdownButton(
value: videoStore.sleepHours,
items: List.generate(24, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(),
onChanged: (int? hours) => videoStore.sleepHours = hours!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Hours'),
],
),
Row(
children: [
DropdownButton(
value: videoStore.sleepMinutes,
items: List.generate(60, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(),
onChanged: (int? minutes) => videoStore.sleepMinutes = minutes!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Minutes'),
],
),
if (videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive)
Row(
children: [
const Icon(Icons.timer),
Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'),
const Spacer(),
IconButton(
tooltip: 'Cancel Timer',
onPressed: videoStore.cancelSleepTimer,
icon: const Icon(Icons.cancel),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Dismiss'),
),
Observer(
builder: (context) => ElevatedButton(
onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0
? null
: () => videoStore.updateSleepTimer(
onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst),
),
child: const Text('Set Timer'),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
final backButton = Align(
alignment: Alignment.topLeft,
child: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.adaptive.arrow_back,
color: Colors.white,
),
onPressed: Navigator.of(context).pop,
),
);
final settingsButton = IconButton(
tooltip: 'Settings',
icon: const Icon(
Icons.settings,
color: Colors.white,
),
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: videoStore.settingsStore),
),
),
);
final chatOverlayButton = IconButton(
tooltip: 'Toggle Chat Overlay',
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay,
icon: const Icon(Icons.chat_bubble_outline),
color: Colors.white,
);
final refreshButton = IconButton(
tooltip: 'Refresh',
icon: const Icon(
Icons.refresh,
color: Colors.white,
),
onPressed: videoStore.handleRefresh,
);
final fullScreenButton = IconButton(
tooltip: videoStore.settingsStore.fullScreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
icon: videoStore.settingsStore.fullScreen
? const Icon(
Icons.fullscreen_exit,
color: Colors.white,
)
: const Icon(
Icons.fullscreen,
color: Colors.white,
),
onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen,
);
final sleepTimerButton = IconButton(
tooltip: 'Sleep Timer',
icon: const Icon(
Icons.timer,
color: Colors.white,
),
onPressed: () => _showSleepTimerDialog(context),
);
final streamInfo = videoStore.streamInfo;
if (streamInfo == null) {
return Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
settingsButton,
],
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
refreshButton,
if (orientation == Orientation.landscape) fullScreenButton,
],
),
),
],
);
}
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) {
return Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
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',
iconSize: 50.0,
icon: videoStore.paused
? const Icon(
Icons.play_arrow,
color: Colors.white,
)
: const Icon(
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(
alignment: Alignment.bottomLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: GestureDetector(
onTap: videoStore.handleExpand,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: videoStore.settingsStore.expandInfo
? 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,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 5.0),
Text(
'${videoStore.streamInfo?.gameName} \u2022 ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers',
style: const TextStyle(
color: Colors.white,
),
),
],
)
: streamer,
),
),
),
if (Platform.isIOS && videoStore.settingsStore.pictureInPicture)
IconButton(
tooltip: 'Picture-in-Picture',
icon: const Icon(
Icons.picture_in_picture_alt_rounded,
color: Colors.white,
),
onPressed: videoStore.requestPictureInPicture,
),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
refreshButton,
if (orientation == Orientation.landscape) fullScreenButton
],
),
)
],
);
},
);
}
}

View file

@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -7,17 +10,20 @@ import 'package:frosty/api/seventv_api.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/screens/channel/stores/video_store.dart';
import 'package:frosty/screens/channel/video/video.dart';
import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget {
final String userId;
final String userName;
@ -66,14 +72,17 @@ class _VideoChatState extends State<VideoChat> {
Widget build(BuildContext context) {
final settingsStore = _chatStore.settings;
final player = Video(
key: _videoKey,
videoStore: _videoStore,
final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
child: _Video(
key: _videoKey,
videoStore: _videoStore,
),
);
final overlay = VideoOverlay(videoStore: _videoStore);
final videoOverlay = _VideoOverlay(videoStore: _videoStore);
final video = GestureDetector(
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onTap: () {
if (_chatStore.assetsStore.showEmoteMenu) {
@ -87,33 +96,37 @@ class _VideoChatState extends State<VideoChat> {
}
},
child: Observer(
builder: (context) {
if (_videoStore.settingsStore.showOverlay) {
return Stack(
children: [
player,
if (_videoStore.paused || _videoStore.streamInfo == null)
overlay
else
AnimatedOpacity(
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ColoredBox(
color: const Color.fromRGBO(0, 0, 0, 0.5),
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
child: overlay,
),
),
),
],
);
}
return player;
builder: (_) {
if (_videoStore.paused || _videoStore.streamInfo == null) return videoOverlay;
return AnimatedOpacity(
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: ColoredBox(
color: Colors.black.withOpacity(settingsStore.overlayOpacity),
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
child: videoOverlay,
),
),
);
},
),
);
final video = Observer(
builder: (context) {
if (!_videoStore.settingsStore.showOverlay) return player;
return Stack(
children: [
player,
overlay,
],
);
},
);
final chat = Chat(
key: _chatKey,
chatStore: _chatStore,
@ -157,12 +170,26 @@ class _VideoChatState extends State<VideoChat> {
duration: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width * _chatStore.settings.landscapeChatWidth,
: MediaQuery.of(context).size.width * _chatStore.settings.chatWidth,
curve: Curves.ease,
color: _chatStore.settings.fullScreen ? null : Theme.of(context).scaffoldBackgroundColor,
color: _chatStore.settings.fullScreen
? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity)
: Theme.of(context).scaffoldBackgroundColor,
child: chat,
);
final overlayChat = Visibility(
visible: settingsStore.fullScreenChatOverlay,
maintainState: true,
child: Theme(
data: darkTheme,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white),
child: landscapeChat,
),
),
);
return ColoredBox(
color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
@ -170,38 +197,23 @@ class _VideoChatState extends State<VideoChat> {
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
alignment: settingsStore.landscapeChatLeftSide ? Alignment.topLeft : Alignment.topRight,
children: [
video,
IgnorePointer(
ignoring: _videoStore.overlayVisible,
child: Visibility(
visible: settingsStore.fullScreenChatOverlay,
maintainState: true,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white),
child: landscapeChat,
),
),
),
player,
if (settingsStore.showOverlay)
Row(
children: settingsStore.landscapeChatLeftSide
? [overlayChat, Expanded(child: overlay)]
: [Expanded(child: overlay), overlayChat],
)
],
)
: Row(
children: settingsStore.landscapeChatLeftSide
? [
landscapeChat,
Expanded(child: video),
]
: [
Expanded(child: video),
landscapeChat,
],
? [landscapeChat, Expanded(child: video)]
: [Expanded(child: video), landscapeChat],
)
: Column(
children: [
appBar,
Expanded(child: chat),
],
children: [appBar, Expanded(child: chat)],
),
),
);
@ -218,13 +230,9 @@ class _VideoChatState extends State<VideoChat> {
children: [
Observer(
builder: (_) {
if (settingsStore.showVideo) {
return AspectRatio(
aspectRatio: 16 / 9,
child: video,
);
}
return appBar;
if (!settingsStore.showVideo) return appBar;
return AspectRatio(aspectRatio: 16 / 9, child: video);
},
),
Expanded(child: chat),
@ -247,6 +255,376 @@ class _VideoChatState extends State<VideoChat> {
overlays: SystemUiOverlay.values,
);
SystemChrome.setPreferredOrientations([]);
super.dispose();
}
}
/// Creates a [WebView] widget that shows a channel's video stream.
class _Video extends StatelessWidget {
final VideoStore videoStore;
const _Video({
Key? key,
required this.videoStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return WebView(
backgroundColor: Colors.black,
initialUrl: 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,
);
}
}
/// Creates a widget containing controls which enable interactions with an underlying [_Video] widget.
class _VideoOverlay extends StatelessWidget {
final VideoStore videoStore;
const _VideoOverlay({Key? key, required this.videoStore}) : super(key: key);
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sleep Timer'),
content: Observer(
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
DropdownButton(
value: videoStore.sleepHours,
items: List.generate(24, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(),
onChanged: (int? hours) => videoStore.sleepHours = hours!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Hours'),
],
),
Row(
children: [
DropdownButton(
value: videoStore.sleepMinutes,
items: List.generate(60, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(),
onChanged: (int? minutes) => videoStore.sleepMinutes = minutes!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Minutes'),
],
),
if (videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive)
Row(
children: [
const Icon(Icons.timer),
Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'),
const Spacer(),
IconButton(
tooltip: 'Cancel Timer',
onPressed: videoStore.cancelSleepTimer,
icon: const Icon(Icons.cancel),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Dismiss'),
),
Observer(
builder: (context) => ElevatedButton(
onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0
? null
: () => videoStore.updateSleepTimer(
onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst),
),
child: const Text('Set Timer'),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
final backButton = Align(
alignment: Alignment.topLeft,
child: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.adaptive.arrow_back,
color: Colors.white,
),
onPressed: Navigator.of(context).pop,
),
);
final settingsButton = IconButton(
tooltip: 'Settings',
icon: const Icon(
Icons.settings,
color: Colors.white,
),
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: videoStore.settingsStore),
),
),
);
final chatOverlayButton = Observer(
builder: (_) => IconButton(
tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide Chat Overlay' : 'Show Chat Overlay',
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay,
icon: videoStore.settingsStore.fullScreenChatOverlay ? const Icon(Icons.chat_bubble_outline) : const Icon(Icons.chat_bubble),
color: Colors.white,
),
);
final refreshButton = IconButton(
tooltip: 'Refresh',
icon: const Icon(
Icons.refresh,
color: Colors.white,
),
onPressed: videoStore.handleRefresh,
);
final fullScreenButton = IconButton(
tooltip: videoStore.settingsStore.fullScreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
icon: videoStore.settingsStore.fullScreen
? const Icon(
Icons.fullscreen_exit,
color: Colors.white,
)
: const Icon(
Icons.fullscreen,
color: Colors.white,
),
onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen,
);
final sleepTimerButton = IconButton(
tooltip: 'Sleep Timer',
icon: const Icon(
Icons.timer,
color: Colors.white,
),
onPressed: () => _showSleepTimerDialog(context),
);
final rotateButton = IconButton(
tooltip: orientation == Orientation.portrait ? 'Enter Landscape Mode' : 'Exit Landscape Mode',
icon: const Icon(
Icons.screen_rotation,
color: Colors.white,
),
onPressed: () {
if (orientation == Orientation.portrait) {
if (Platform.isIOS) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
]);
SystemChrome.setPreferredOrientations([]);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
DeviceOrientation.landscapeLeft,
]);
}
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
SystemChrome.setPreferredOrientations([]);
}
},
);
final streamInfo = videoStore.streamInfo;
if (streamInfo == null) {
return Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
settingsButton,
],
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
refreshButton,
if (!videoStore.isIPad) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
],
),
),
],
);
}
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) {
return Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
const Spacer(),
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',
iconSize: 50.0,
icon: videoStore.paused
? const Icon(
Icons.play_arrow,
color: Colors.white,
)
: const Icon(
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(
alignment: Alignment.bottomLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: GestureDetector(
onTap: videoStore.handleExpand,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: videoStore.settingsStore.expandInfo
? 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,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 5.0),
Text(
'${videoStore.streamInfo?.gameName} \u2022 ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers',
style: const TextStyle(
color: Colors.white,
),
),
],
)
: streamer,
),
),
),
if (Platform.isIOS && videoStore.settingsStore.pictureInPicture)
IconButton(
tooltip: 'Picture-in-Picture',
icon: const Icon(
Icons.picture_in_picture_alt_rounded,
color: Colors.white,
),
onPressed: videoStore.requestPictureInPicture,
),
refreshButton,
if (!videoStore.isIPad) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
],
),
)
],
);
},
);
}
}

View file

@ -38,6 +38,14 @@ class _ChatSettingsState extends State<ChatSettings> {
),
),
children: [
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes and mentions when typing.'),
value: settingsStore.autocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null,
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
@ -75,15 +83,32 @@ class _ChatSettingsState extends State<ChatSettings> {
children: [
const Text('Landscape Chat Width'),
const Spacer(),
Text('${(settingsStore.landscapeChatWidth * 100).toInt()}%'),
Text('${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.landscapeChatWidth,
value: settingsStore.chatWidth,
min: 0.2,
max: 0.8,
divisions: 12,
onChanged: (newValue) => settingsStore.landscapeChatWidth = newValue,
max: 0.6,
divisions: 8,
onChanged: (newValue) => settingsStore.chatWidth = newValue,
),
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
const Text('Landscape Chat Overlay Opacity'),
const Spacer(),
Text('${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.fullScreenChatOverlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue,
),
),
const SectionHeader(
@ -91,13 +116,6 @@ class _ChatSettingsState extends State<ChatSettings> {
fontSize: 12.0,
padding: sectionPadding,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Emote Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes when typing.'),
value: settingsStore.emoteAutocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.emoteAutocomplete = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Zero-Width Emotes'),

View file

@ -49,6 +49,23 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.toggleableOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null,
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
const Text('Custom Overlay Opacity'),
const Spacer(),
Text('${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.overlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.overlayOpacity = newValue,
),
),
],
),
);

View file

@ -46,6 +46,8 @@ abstract class _SettingsStoreBase with Store {
static const defaultPictureInPicture = false;
static const defaultOverlayOpacity = 0.5;
@JsonKey(defaultValue: defaultShowVideo)
@observable
var showVideo = defaultShowVideo;
@ -62,6 +64,10 @@ abstract class _SettingsStoreBase with Store {
@observable
var pictureInPicture = defaultPictureInPicture;
@JsonKey(defaultValue: defaultOverlayOpacity)
@observable
var overlayOpacity = defaultOverlayOpacity;
// * Chat Settings
static const defaultShowBottomBar = true;
@ -85,14 +91,16 @@ abstract class _SettingsStoreBase with Store {
static const defaultEmoteScale = 1.0;
static const defaultEmoteAutocomplete = true;
static const defaultAutocomplete = true;
static const defaultLandscapeChatWidth = 0.3;
static const defaultChatWidth = 0.3;
static const defaultLandscapeChatLeftSide = false;
static const defaultChatDelay = 0.0;
static const defaultFullScreenChatOverlayOpacity = 0.5;
@JsonKey(defaultValue: defaultShowBottomBar)
@observable
var showBottomBar = defaultShowBottomBar;
@ -137,18 +145,22 @@ abstract class _SettingsStoreBase with Store {
@observable
var emoteScale = defaultEmoteScale;
@JsonKey(defaultValue: defaultEmoteAutocomplete)
@JsonKey(defaultValue: defaultAutocomplete)
@observable
var emoteAutocomplete = defaultEmoteAutocomplete;
var autocomplete = defaultAutocomplete;
@JsonKey(defaultValue: defaultLandscapeChatWidth)
@JsonKey(defaultValue: defaultChatWidth)
@observable
var landscapeChatWidth = defaultLandscapeChatWidth;
var chatWidth = defaultChatWidth;
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
@observable
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
@observable
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
// * Other settings
static const defaultSendCrashLogs = true;
@ -192,6 +204,7 @@ abstract class _SettingsStoreBase with Store {
showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay;
pictureInPicture = defaultPictureInPicture;
overlayOpacity = defaultOverlayOpacity;
// * Chat Settings
showBottomBar = defaultShowBottomBar;
@ -205,10 +218,11 @@ abstract class _SettingsStoreBase with Store {
messageSpacing = defaultMessageSpacing;
badgeScale = defaultBadgeScale;
emoteScale = defaultEmoteScale;
emoteAutocomplete = defaultEmoteAutocomplete;
landscapeChatWidth = defaultLandscapeChatWidth;
autocomplete = defaultAutocomplete;
chatWidth = defaultChatWidth;
landscapeChatLeftSide = false;
chatDelay = defaultChatDelay;
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
// * Other settings
sendCrashLogs = defaultSendCrashLogs;

View file

@ -18,6 +18,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
..pictureInPicture = json['pictureInPicture'] as bool? ?? false
..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5
..showBottomBar = json['showBottomBar'] as bool? ?? true
..showDeletedMessages = json['showDeletedMessages'] as bool? ?? false
..showZeroWidth = json['showZeroWidth'] as bool? ?? false
@ -33,10 +34,11 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0
..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0
..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0
..emoteAutocomplete = json['emoteAutocomplete'] as bool? ?? true
..landscapeChatWidth =
(json['landscapeChatWidth'] as num?)?.toDouble() ?? 0.3
..autocomplete = json['autocomplete'] as bool? ?? true
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
..fullScreenChatOverlayOpacity =
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
..sendCrashLogs = json['sendCrashLogs'] as bool? ?? true
..fullScreen = json['fullScreen'] as bool? ?? false
..expandInfo = json['expandInfo'] as bool? ?? true
@ -53,6 +55,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay,
'pictureInPicture': instance.pictureInPicture,
'overlayOpacity': instance.overlayOpacity,
'showBottomBar': instance.showBottomBar,
'showDeletedMessages': instance.showDeletedMessages,
'showZeroWidth': instance.showZeroWidth,
@ -64,9 +67,10 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'messageScale': instance.messageScale,
'badgeScale': instance.badgeScale,
'emoteScale': instance.emoteScale,
'emoteAutocomplete': instance.emoteAutocomplete,
'landscapeChatWidth': instance.landscapeChatWidth,
'autocomplete': instance.autocomplete,
'chatWidth': instance.chatWidth,
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
'sendCrashLogs': instance.sendCrashLogs,
'fullScreen': instance.fullScreen,
'expandInfo': instance.expandInfo,
@ -222,6 +226,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$overlayOpacityAtom =
Atom(name: '_SettingsStoreBase.overlayOpacity', context: context);
@override
double get overlayOpacity {
_$overlayOpacityAtom.reportRead();
return super.overlayOpacity;
}
@override
set overlayOpacity(double value) {
_$overlayOpacityAtom.reportWrite(value, super.overlayOpacity, () {
super.overlayOpacity = value;
});
}
late final _$showBottomBarAtom =
Atom(name: '_SettingsStoreBase.showBottomBar', context: context);
@ -399,35 +419,35 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$emoteAutocompleteAtom =
Atom(name: '_SettingsStoreBase.emoteAutocomplete', context: context);
late final _$autocompleteAtom =
Atom(name: '_SettingsStoreBase.autocomplete', context: context);
@override
bool get emoteAutocomplete {
_$emoteAutocompleteAtom.reportRead();
return super.emoteAutocomplete;
bool get autocomplete {
_$autocompleteAtom.reportRead();
return super.autocomplete;
}
@override
set emoteAutocomplete(bool value) {
_$emoteAutocompleteAtom.reportWrite(value, super.emoteAutocomplete, () {
super.emoteAutocomplete = value;
set autocomplete(bool value) {
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
super.autocomplete = value;
});
}
late final _$landscapeChatWidthAtom =
Atom(name: '_SettingsStoreBase.landscapeChatWidth', context: context);
late final _$chatWidthAtom =
Atom(name: '_SettingsStoreBase.chatWidth', context: context);
@override
double get landscapeChatWidth {
_$landscapeChatWidthAtom.reportRead();
return super.landscapeChatWidth;
double get chatWidth {
_$chatWidthAtom.reportRead();
return super.chatWidth;
}
@override
set landscapeChatWidth(double value) {
_$landscapeChatWidthAtom.reportWrite(value, super.landscapeChatWidth, () {
super.landscapeChatWidth = value;
set chatWidth(double value) {
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
super.chatWidth = value;
});
}
@ -448,6 +468,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$fullScreenChatOverlayOpacityAtom = Atom(
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
context: context);
@override
double get fullScreenChatOverlayOpacity {
_$fullScreenChatOverlayOpacityAtom.reportRead();
return super.fullScreenChatOverlayOpacity;
}
@override
set fullScreenChatOverlayOpacity(double value) {
_$fullScreenChatOverlayOpacityAtom
.reportWrite(value, super.fullScreenChatOverlayOpacity, () {
super.fullScreenChatOverlayOpacity = value;
});
}
late final _$sendCrashLogsAtom =
Atom(name: '_SettingsStoreBase.sendCrashLogs', context: context);
@ -554,6 +592,7 @@ showVideo: ${showVideo},
showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
pictureInPicture: ${pictureInPicture},
overlayOpacity: ${overlayOpacity},
showBottomBar: ${showBottomBar},
showDeletedMessages: ${showDeletedMessages},
showZeroWidth: ${showZeroWidth},
@ -565,9 +604,10 @@ messageSpacing: ${messageSpacing},
messageScale: ${messageScale},
badgeScale: ${badgeScale},
emoteScale: ${emoteScale},
emoteAutocomplete: ${emoteAutocomplete},
landscapeChatWidth: ${landscapeChatWidth},
autocomplete: ${autocomplete},
chatWidth: ${chatWidth},
landscapeChatLeftSide: ${landscapeChatLeftSide},
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
sendCrashLogs: ${sendCrashLogs},
fullScreen: ${fullScreen},
expandInfo: ${expandInfo},

View file

@ -168,7 +168,7 @@ packages:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.0.2"
crypto:
dependency: transitive
description:
@ -182,7 +182,7 @@ packages:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "1.0.5"
dart_style:
dependency: transitive
description:
@ -190,6 +190,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.3"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.4"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.3"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.0+1"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
fake_async:
dependency: transitive
description:
@ -243,7 +285,7 @@ packages:
name: flutter_launcher_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.2"
version: "0.9.3"
flutter_lints:
dependency: "direct dev"
description:
@ -264,7 +306,7 @@ packages:
name: flutter_native_splash
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
version: "2.2.3"
flutter_secure_storage:
dependency: "direct main"
description:
@ -575,7 +617,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.10"
version: "2.0.11"
path_provider_android:
dependency: transitive
description:
@ -596,7 +638,7 @@ packages:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.6"
version: "2.1.7"
path_provider_macos:
dependency: transitive
description:
@ -617,7 +659,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
version: "2.0.7"
pedantic:
dependency: transitive
description:
@ -694,7 +736,7 @@ packages:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
version: "0.27.3"
version: "0.27.4"
sentry:
dependency: transitive
description:
@ -902,7 +944,7 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
version: "6.1.3"
url_launcher_android:
dependency: transitive
description:
@ -993,21 +1035,21 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.9"
version: "2.8.11"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.0"
version: "1.9.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.0"
version: "2.8.1"
win32:
dependency: transitive
description:

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.4.1+23
version: 1.5.0+24
environment:
sdk: ">=2.15.0 <3.0.0"
@ -54,6 +54,7 @@ dependencies:
package_info_plus: ^1.3.0
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^3.2.3
dev_dependencies:
flutter_test: