mirror of
https://github.com/Cateners/tiny_computer.git
synced 2026-05-20 16:35:47 +08:00
Organize the code
This commit is contained in:
930
lib/main.dart
930
lib/main.dart
@@ -94,6 +94,276 @@ class _FakeLoadingStatusState extends State<FakeLoadingStatus> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SettingPage extends StatefulWidget {
|
||||||
|
const SettingPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingPage> createState() => _SettingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingPageState extends State<SettingPage> {
|
||||||
|
|
||||||
|
final List<bool> _expandState = [false, false, false, false, false];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ExpansionPanelList(
|
||||||
|
elevation: 1,
|
||||||
|
expandedHeaderPadding: const EdgeInsets.all(0),
|
||||||
|
expansionCallback: (panelIndex, isExpanded) {
|
||||||
|
setState(() {
|
||||||
|
_expandState[panelIndex] = isExpanded;
|
||||||
|
});
|
||||||
|
},children: [
|
||||||
|
ExpansionPanel(
|
||||||
|
isExpanded: _expandState[0],
|
||||||
|
headerBuilder: ((context, isExpanded) {
|
||||||
|
return const ListTile(title: Text("高级设置"), subtitle: Text("修改后重启生效"));
|
||||||
|
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
||||||
|
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("name"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "容器名称"), onChanged: (value) async {
|
||||||
|
await Util.setCurrentProp("name", value);
|
||||||
|
//setState(() {});
|
||||||
|
}),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("boot"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "启动命令"), onChanged: (value) async {
|
||||||
|
await Util.setCurrentProp("boot", value);
|
||||||
|
}),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("vnc"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "vnc启动命令"), onChanged: (value) async {
|
||||||
|
await Util.setCurrentProp("vnc", value);
|
||||||
|
}),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("vncUrl"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "网页跳转地址"), onChanged: (value) async {
|
||||||
|
await Util.setCurrentProp("vncUrl", value);
|
||||||
|
}),
|
||||||
|
],))),
|
||||||
|
ExpansionPanel(
|
||||||
|
isExpanded: _expandState[1],
|
||||||
|
headerBuilder: ((context, isExpanded) {
|
||||||
|
return const ListTile(title: Text("全局设置"), subtitle: Text("在这里关广告、开启终端编辑"));
|
||||||
|
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
||||||
|
TextFormField(autovalidateMode: AutovalidateMode.onUserInteraction, initialValue: (Util.getGlobal("termMaxLines") as int).toString(), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "终端最大行数(重启软件生效)"), readOnly: Util.shouldWatchAds(D.adsRequired["changeTermMaxLines"]!),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onTap: () {
|
||||||
|
if (Util.shouldWatchAds(D.adsRequired["changeTermMaxLines"]!)) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("观看六次视频广告永久解锁><"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validator: (value) {
|
||||||
|
return Util.validateBetween(value, 1024, 2147483647, () async {
|
||||||
|
await G.prefs.setInt("termMaxLines", int.parse(value!));
|
||||||
|
});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(16)),
|
||||||
|
TextFormField(autovalidateMode: AutovalidateMode.onUserInteraction, initialValue: (Util.getGlobal("defaultAudioPort") as int).toString(), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "pulseaudio接收端口"),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (value) {
|
||||||
|
return Util.validateBetween(value, 0, 65535, () async {
|
||||||
|
await G.prefs.setInt("defaultAudioPort", int.parse(value!));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
SizedBox.fromSize(size: const Size.square(16)),
|
||||||
|
SwitchListTile(title: const Text("关闭横幅广告"), value: Util.getGlobal("isBannerAdsClosed") as bool, onChanged:(value) {
|
||||||
|
if (value && Util.shouldWatchAds(D.adsRequired["closeBannerAds"]!)) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("观看五次视频广告永久解锁><"))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
G.prefs.setBool("isBannerAdsClosed", value);
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
SwitchListTile(title: const Text("启用终端"), value: Util.getGlobal("isTerminalWriteEnabled") as bool, onChanged:(value) {
|
||||||
|
if (value && Util.shouldWatchAds(D.adsRequired["enableTerminalWrite"]!)) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: const Text("观看两次视频广告永久解锁><"), action: SnackBarAction(label: "啊?", onPressed: () {
|
||||||
|
G.prefs.setBool("isTerminalWriteEnabled", value);
|
||||||
|
setState(() {});
|
||||||
|
},))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
G.prefs.setBool("isTerminalWriteEnabled", value);
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
SwitchListTile(title: const Text("启用终端小键盘"), value: Util.getGlobal("isTerminalCommandsEnabled") as bool, onChanged:(value) {
|
||||||
|
if (value && Util.shouldWatchAds(D.adsRequired["enableTerminalCommands"]!)) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("观看三次视频广告永久解锁><"))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
G.prefs.setBool("isTerminalCommandsEnabled", value);
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
SwitchListTile(title: const Text("终端粘滞键"), value: Util.getGlobal("isStickyKey") as bool, onChanged:(value) {
|
||||||
|
G.prefs.setBool("isStickyKey", value);
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
SwitchListTile(title: const Text("开启时启动图形界面"), value: Util.getGlobal("autoLaunchVnc") as bool, onChanged:(value) {
|
||||||
|
G.prefs.setBool("autoLaunchVnc", value);
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
],))),
|
||||||
|
ExpansionPanel(
|
||||||
|
isExpanded: _expandState[2],
|
||||||
|
headerBuilder: ((context, isExpanded) {
|
||||||
|
return const ListTile(title: Text("相机推流"), subtitle: Text("实验性功能"));
|
||||||
|
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
||||||
|
const Text("成功启动推流后可以点击快捷指令\"拉流测试\"并前往图形界面查看效果。\n注意这并不能为系统创建一个虚拟相机;\n另外使用相机是高耗电行为,不用时需及时关闭。"),
|
||||||
|
const SizedBox.square(dimension: 16),
|
||||||
|
Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: [
|
||||||
|
OutlinedButton(style: D.commandButtonStyle, child: const Text("申请相机权限"), onPressed: () {
|
||||||
|
Permission.camera.request();
|
||||||
|
}),
|
||||||
|
OutlinedButton(style: D.commandButtonStyle, child: const Text("申请麦克风权限"), onPressed: () {
|
||||||
|
Permission.microphone.request();
|
||||||
|
}),
|
||||||
|
OutlinedButton(style: D.commandButtonStyle, child: const Text("查看输出"), onPressed: () {
|
||||||
|
if (G.streamingOutput == "") {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("无输出"))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDialog(context: context, builder: (context) {
|
||||||
|
return AlertDialog(content: SingleChildScrollView(child:
|
||||||
|
Text(G.streamingOutput)), actions: [
|
||||||
|
TextButton(onPressed:() {
|
||||||
|
FlutterClipboard.copy(G.streamingOutput).then(( value ) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("已复制")));
|
||||||
|
});
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("复制")),
|
||||||
|
TextButton(onPressed:() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("取消")),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
const SizedBox.square(dimension: 16),
|
||||||
|
TextFormField(maxLines: null, initialValue: Util.getGlobal("defaultFFmpegCommand") as String, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "ffmpeg推流命令"), readOnly: Util.shouldWatchAds(D.adsRequired["changeFFmpegCommand"]!),
|
||||||
|
onTap: () {
|
||||||
|
if (Util.shouldWatchAds(D.adsRequired["changeFFmpegCommand"]!)) {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("观看八次视频广告永久解锁><"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChanged: (value) async {
|
||||||
|
await G.prefs.setString("defaultFFmpegCommand", value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox.square(dimension: 16),
|
||||||
|
SwitchListTile(title: const Text("启动推流服务器"), subtitle: const Text("mediamtx"), value: G.isStreamServerStarted, onChanged:(value) {
|
||||||
|
switch (value) {
|
||||||
|
case true: {
|
||||||
|
G.streamServerPty = Pty.start("/system/bin/sh");
|
||||||
|
G.streamServerPty.write(const Utf8Encoder().convert("${G.dataPath}/bin/mediamtx ${G.dataPath}/bin/mediamtx.yml & pid=\$(echo \$!)\n"));
|
||||||
|
G.streamServerPty.exitCode.then((value) {
|
||||||
|
G.isStreamServerStarted = false;
|
||||||
|
setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case false: {
|
||||||
|
G.streamServerPty.write(const Utf8Encoder().convert("kill \$pid\nexit\n"));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
G.isStreamServerStarted = value;
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
SwitchListTile(title: const Text("启动推流"), value: G.isStreaming, onChanged:(value) {
|
||||||
|
switch (value) {
|
||||||
|
case true: {
|
||||||
|
FFmpegKit.execute(Util.getGlobal("defaultFFmpegCommand") as String).then((session) {
|
||||||
|
session.getOutput().then((value) async {
|
||||||
|
G.isStreaming = false;
|
||||||
|
G.streamingOutput = value??"";
|
||||||
|
setState(() {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case false: {
|
||||||
|
FFmpegKit.cancel();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
G.isStreaming = value;
|
||||||
|
setState(() {});
|
||||||
|
},),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8))
|
||||||
|
],))),
|
||||||
|
ExpansionPanel(
|
||||||
|
isExpanded: _expandState[3],
|
||||||
|
headerBuilder: ((context, isExpanded) {
|
||||||
|
return const ListTile(title: Text("文件访问"));
|
||||||
|
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
||||||
|
const Text("通过这里获取更多文件权限,以实现对特殊目录的访问。"),
|
||||||
|
SizedBox.fromSize(size: const Size.square(16)),
|
||||||
|
Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: [
|
||||||
|
OutlinedButton(style: D.commandButtonStyle, child: const Text("申请存储权限"), onPressed: () {
|
||||||
|
Permission.storage.request();
|
||||||
|
}),
|
||||||
|
OutlinedButton(style: D.commandButtonStyle, child: const Text("申请所有文件访问权限"), onPressed: () {
|
||||||
|
Permission.manageExternalStorage.request();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
SizedBox.fromSize(size: const Size.square(16)),
|
||||||
|
],))),
|
||||||
|
ExpansionPanel(
|
||||||
|
isExpanded: _expandState[4],
|
||||||
|
headerBuilder: ((context, isExpanded) {
|
||||||
|
return const ListTile(title: Text("广告记录"), subtitle: Text("在这里看广告"));
|
||||||
|
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
||||||
|
OutlinedButton(child: const Text("看一个广告"), onPressed: () {
|
||||||
|
if (AdManager.placements[AdManager.rewardedVideoAdPlacementId]!) {
|
||||||
|
AdManager.showAd(AdManager.rewardedVideoAdPlacementId, () {
|
||||||
|
final bonus = Util.getRandomBonus();
|
||||||
|
Util.applyBonus(bonus);
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("你获得了 ${bonus["name"]}*${bonus["amount"]}"))
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
|
||||||
|
});
|
||||||
|
}, () {
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("已经看5个广告了, 今天也非常感谢><"))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
const SizedBox.square(dimension: 8),
|
||||||
|
Text(Util.getGlobal("adsBonus").map((element) {
|
||||||
|
final e = jsonDecode(element);
|
||||||
|
return e["amount"]==0?"":"${e["name"]}*${e["amount"]}\n";
|
||||||
|
}).join())
|
||||||
|
],))),
|
||||||
|
],);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class InfoPage extends StatefulWidget {
|
class InfoPage extends StatefulWidget {
|
||||||
const InfoPage({super.key});
|
const InfoPage({super.key});
|
||||||
|
|
||||||
@@ -119,7 +389,12 @@ class _InfoPageState extends State<InfoPage> {
|
|||||||
return const ListTile(title: Text("使用说明"));
|
return const ListTile(title: Text("使用说明"));
|
||||||
},
|
},
|
||||||
body: const Padding(padding: EdgeInsets.all(8), child: Text("""
|
body: const Padding(padding: EdgeInsets.all(8), child: Text("""
|
||||||
第一次加载, 大概需要5到10分钟...
|
第一次加载大概需要5到10分钟...
|
||||||
|
加载完成后,软件会自动跳转到图形界面
|
||||||
|
|
||||||
|
在图形界面返回,可以回到终端界面和控制界面
|
||||||
|
你可以在控制界面安装更多软件或者阅读帮助信息
|
||||||
|
|
||||||
请不要在安装时退出软件
|
请不要在安装时退出软件
|
||||||
|
|
||||||
如果过了很长时间都没有加载完成
|
如果过了很长时间都没有加载完成
|
||||||
@@ -723,36 +998,34 @@ class LoadingPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16),
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
child: FractionallySizedBox(
|
child: FractionallySizedBox(
|
||||||
widthFactor: 0.4,
|
widthFactor: 0.4,
|
||||||
child: Image(
|
child: Image(
|
||||||
image: AssetImage("images/icon.png")
|
image: AssetImage("images/icon.png")
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(0, 0, 0, 8),
|
|
||||||
child: Text("小小电脑", textScaleFactor: 2),
|
|
||||||
),
|
|
||||||
FakeLoadingStatus(),
|
|
||||||
Expanded(child:
|
|
||||||
Padding(padding: EdgeInsets.all(8), child: Card(child: Padding(padding: EdgeInsets.all(8), child:
|
|
||||||
|
|
||||||
Scrollbar(child:
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: InfoPage()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
))
|
|
||||||
,))
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
);
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(0, 0, 0, 8),
|
||||||
|
child: Text("小小电脑", textScaleFactor: 2),
|
||||||
|
),
|
||||||
|
FakeLoadingStatus(),
|
||||||
|
Expanded(child: Padding(padding: EdgeInsets.all(8), child: Card(child: Padding(padding: EdgeInsets.all(8), child:
|
||||||
|
Scrollbar(child:
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: InfoPage()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
,))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +1054,145 @@ RawGestureDetector forceScaleGestureDetector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TerminalPage extends StatelessWidget {
|
||||||
|
const TerminalPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(children: [Expanded(child: forceScaleGestureDetector(onScaleUpdate: (details) {
|
||||||
|
G.termFontScale.value = (details.scale * (Util.getGlobal("termFontScale") as double)).clamp(0.2, 5);
|
||||||
|
}, onScaleEnd: (details) async {
|
||||||
|
await G.prefs.setDouble("termFontScale", G.termFontScale.value);
|
||||||
|
}, child: ValueListenableBuilder(valueListenable: G.termFontScale, builder:(context, value, child) {
|
||||||
|
return TerminalView(G.termPtys[G.currentContainer]!.terminal, textScaleFactor: G.termFontScale.value, keyboardType: TextInputType.multiline);
|
||||||
|
},) )),
|
||||||
|
(Util.getGlobal("isTerminalCommandsEnabled") as bool)?Padding(padding: const EdgeInsets.all(8), child: Row(children: [AnimatedBuilder(
|
||||||
|
animation: G.keyboard,
|
||||||
|
builder: (context, child) => ToggleButtons(
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 24),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
isSelected: [G.keyboard.ctrl, G.keyboard.alt, G.keyboard.shift],
|
||||||
|
onPressed: (index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
G.keyboard.ctrl = !G.keyboard.ctrl;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
G.keyboard.alt = !G.keyboard.alt;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
G.keyboard.shift = !G.keyboard.shift;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: const [Text('Ctrl'), Text('Alt'), Text('Shift')],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
Expanded(child: SizedBox(height: 24, child: ListView.separated(scrollDirection: Axis.horizontal, itemBuilder:(context, index) {
|
||||||
|
return OutlinedButton(style: D.controlButtonStyle, onPressed: () {
|
||||||
|
G.termPtys[G.currentContainer]!.terminal.keyInput(D.termCommands[index]["key"]! as TerminalKey);
|
||||||
|
}, child: Text(D.termCommands[index]["name"]! as String));
|
||||||
|
}, separatorBuilder:(context, index) {
|
||||||
|
return SizedBox.fromSize(size: const Size.square(4));
|
||||||
|
}, itemCount: D.termCommands.length))), SizedBox.fromSize(size: const Size(72, 0))])):SizedBox.fromSize(size: const Size.square(0))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FastCommands extends StatefulWidget {
|
||||||
|
const FastCommands({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FastCommands> createState() => _FastCommandsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FastCommandsState extends State<FastCommands> {
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: Util.getCurrentProp("commands")
|
||||||
|
.asMap().entries.map<Widget>((e) {
|
||||||
|
return OutlinedButton(style: D.commandButtonStyle, child: Text(e.value["name"]!), onPressed: () {
|
||||||
|
Util.termWrite(e.value["command"]!);
|
||||||
|
G.pageIndex.value = 0;
|
||||||
|
}, onLongPress: () {
|
||||||
|
String name = e.value["name"]!;
|
||||||
|
String command = e.value["command"]!;
|
||||||
|
showDialog(context: context, builder: (context) {
|
||||||
|
return AlertDialog(title: const Text("指令编辑"), content: SingleChildScrollView(child: Column(children: [
|
||||||
|
TextFormField(initialValue: name, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令名称"), onChanged: (value) {
|
||||||
|
name = value;
|
||||||
|
}),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
TextFormField(maxLines: null, initialValue: command, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令内容"), onChanged: (value) {
|
||||||
|
command = value;
|
||||||
|
}),
|
||||||
|
])), actions: [
|
||||||
|
TextButton(onPressed:() async {
|
||||||
|
await Util.setCurrentProp("commands", Util.getCurrentProp("commands")
|
||||||
|
..removeAt(e.key));
|
||||||
|
setState(() {});
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("删除该项")),
|
||||||
|
TextButton(onPressed:() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("取消")),
|
||||||
|
TextButton(onPressed:() async {
|
||||||
|
await Util.setCurrentProp("commands", Util.getCurrentProp("commands")
|
||||||
|
..setAll(e.key, [{"name": name, "command": command}]));
|
||||||
|
setState(() {});
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("保存")),
|
||||||
|
]);
|
||||||
|
},);
|
||||||
|
},);
|
||||||
|
}).toList()..add(OutlinedButton(style: D.commandButtonStyle, onPressed:() {
|
||||||
|
String name = "";
|
||||||
|
String command = "";
|
||||||
|
showDialog(context: context, builder: (context) {
|
||||||
|
return AlertDialog(title: const Text("指令编辑"), content: SingleChildScrollView(child: Column(children: [
|
||||||
|
TextFormField(initialValue: name, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令名称"), onChanged: (value) {
|
||||||
|
name = value;
|
||||||
|
}),
|
||||||
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
|
TextFormField(maxLines: null, initialValue: command, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令内容"), onChanged: (value) {
|
||||||
|
command = value;
|
||||||
|
}),
|
||||||
|
])), actions: [
|
||||||
|
TextButton(onPressed:() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("取消")),
|
||||||
|
TextButton(onPressed:() async {
|
||||||
|
await Util.setCurrentProp("commands", Util.getCurrentProp("commands")
|
||||||
|
..add({"name": name, "command": command}));
|
||||||
|
setState(() {});
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("添加")),
|
||||||
|
]);
|
||||||
|
},);
|
||||||
|
}, onLongPress: () {
|
||||||
|
showDialog(context: context, builder: (context) {
|
||||||
|
return AlertDialog(content: const Text("是否重置所有快捷指令?"), actions: [
|
||||||
|
TextButton(onPressed:() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("取消")),
|
||||||
|
TextButton(onPressed:() async {
|
||||||
|
await Util.setCurrentProp("commands", D.commands);
|
||||||
|
setState(() {});
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}, child: const Text("是")),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}, child: const Text("添加快捷指令"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MyHomePage extends StatefulWidget {
|
class MyHomePage extends StatefulWidget {
|
||||||
const MyHomePage({super.key, required this.title});
|
const MyHomePage({super.key, required this.title});
|
||||||
|
|
||||||
@@ -792,30 +1204,11 @@ class MyHomePage extends StatefulWidget {
|
|||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
|
|
||||||
//高级设置,全局设置
|
|
||||||
final List<bool> _expandState = [false, false, false, false, false];
|
|
||||||
|
|
||||||
bool bannerAdsFailedToLoad = false;
|
bool bannerAdsFailedToLoad = false;
|
||||||
|
|
||||||
//安装完成了吗?
|
//安装完成了吗?
|
||||||
//完成后从加载界面切换到主界面
|
//完成后从加载界面切换到主界面
|
||||||
bool isLoadingComplete = false;
|
bool isLoadingComplete = false;
|
||||||
//主界面索引
|
|
||||||
int pageIndex = 0;
|
|
||||||
|
|
||||||
final ButtonStyle commandButtonStyle = OutlinedButton.styleFrom(
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
minimumSize: const Size(0, 0),
|
|
||||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
final ButtonStyle controlButtonStyle = OutlinedButton.styleFrom(
|
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w400),
|
|
||||||
side: const BorderSide(color: Color(0x1F000000)),
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
minimumSize: const Size(0, 0),
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4)
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -847,48 +1240,8 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
bannerAdsFailedToLoad = true;
|
bannerAdsFailedToLoad = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
), Expanded(child: AnimatedSwitcher(
|
), Expanded(child: ValueListenableBuilder(valueListenable: G.pageIndex, builder: (context, value, child) {
|
||||||
duration: const Duration(milliseconds: 256),
|
return IndexedStack(index: G.pageIndex.value, children: [const TerminalPage(), Padding(
|
||||||
child: [
|
|
||||||
Column(children: [Expanded(child: forceScaleGestureDetector(onScaleUpdate: (details) {
|
|
||||||
setState(() {
|
|
||||||
G.termFontScale = (details.scale * (Util.getGlobal("termFontScale") as double)).clamp(0.2, 5);
|
|
||||||
});
|
|
||||||
}, onScaleEnd: (details) async {
|
|
||||||
await G.prefs.setDouble("termFontScale", G.termFontScale);
|
|
||||||
}, child: TerminalView(G.termPtys[G.currentContainer]!.terminal, textScaleFactor: G.termFontScale, keyboardType: TextInputType.multiline,))),
|
|
||||||
(Util.getGlobal("isTerminalCommandsEnabled") as bool)?Padding(padding: const EdgeInsets.all(8), child: Row(children: [AnimatedBuilder(
|
|
||||||
animation: G.keyboard,
|
|
||||||
builder: (context, child) => ToggleButtons(
|
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 24),
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
||||||
isSelected: [G.keyboard.ctrl, G.keyboard.alt, G.keyboard.shift],
|
|
||||||
onPressed: (index) {
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
G.keyboard.ctrl = !G.keyboard.ctrl;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
G.keyboard.alt = !G.keyboard.alt;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
G.keyboard.shift = !G.keyboard.shift;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
children: const [Text('Ctrl'), Text('Alt'), Text('Shift')],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
Expanded(child: SizedBox(height: 24, child: ListView.separated(scrollDirection: Axis.horizontal, itemBuilder:(context, index) {
|
|
||||||
return OutlinedButton(style: controlButtonStyle, onPressed: () {
|
|
||||||
G.termPtys[G.currentContainer]!.terminal.keyInput(D.termCommands[index]["key"]! as TerminalKey);
|
|
||||||
}, child: Text(D.termCommands[index]["name"]! as String));
|
|
||||||
}, separatorBuilder:(context, index) {
|
|
||||||
return SizedBox.fromSize(size: const Size.square(4));
|
|
||||||
}, itemCount: D.termCommands.length))), SizedBox.fromSize(size: const Size(72, 0))])):SizedBox.fromSize(size: const Size.square(0))
|
|
||||||
]), Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Scrollbar(child: SingleChildScrollView(restorationId: "control-scroll", child: Column(
|
child: Scrollbar(child: SingleChildScrollView(restorationId: "control-scroll", child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -905,374 +1258,39 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 8),
|
padding: const EdgeInsets.fromLTRB(0, 0, 0, 8),
|
||||||
child: Text(Util.getCurrentProp("name"), textScaleFactor: 2),
|
child: Text(Util.getCurrentProp("name"), textScaleFactor: 2),
|
||||||
),*/
|
),*/
|
||||||
Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: Util.getCurrentProp("commands")
|
const FastCommands(),
|
||||||
.asMap().entries.map<Widget>((e) {
|
|
||||||
return OutlinedButton(style: commandButtonStyle, child: Text(e.value["name"]!), onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
Util.termWrite(e.value["command"]!);
|
|
||||||
pageIndex = 0;
|
|
||||||
});
|
|
||||||
}, onLongPress: () {
|
|
||||||
String name = e.value["name"]!;
|
|
||||||
String command = e.value["command"]!;
|
|
||||||
showDialog(context: context, builder: (context) {
|
|
||||||
return AlertDialog(title: const Text("指令编辑"), content: SingleChildScrollView(child: Column(children: [
|
|
||||||
TextFormField(initialValue: name, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令名称"), onChanged: (value) {
|
|
||||||
name = value;
|
|
||||||
}),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
TextFormField(maxLines: null, initialValue: command, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令内容"), onChanged: (value) {
|
|
||||||
command = value;
|
|
||||||
}),
|
|
||||||
])), actions: [
|
|
||||||
TextButton(onPressed:() async {
|
|
||||||
await Util.setCurrentProp("commands", Util.getCurrentProp("commands")
|
|
||||||
..removeAt(e.key));
|
|
||||||
setState(() {});
|
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("删除该项")),
|
|
||||||
TextButton(onPressed:() {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("取消")),
|
|
||||||
TextButton(onPressed:() async {
|
|
||||||
await Util.setCurrentProp("commands", Util.getCurrentProp("commands")
|
|
||||||
..setAll(e.key, [{"name": name, "command": command}]));
|
|
||||||
setState(() {});
|
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("保存")),
|
|
||||||
]);
|
|
||||||
},);
|
|
||||||
},);
|
|
||||||
}).toList()..add(OutlinedButton(style: commandButtonStyle, onPressed:() {
|
|
||||||
String name = "";
|
|
||||||
String command = "";
|
|
||||||
showDialog(context: context, builder: (context) {
|
|
||||||
return AlertDialog(title: const Text("指令编辑"), content: SingleChildScrollView(child: Column(children: [
|
|
||||||
TextFormField(initialValue: name, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令名称"), onChanged: (value) {
|
|
||||||
name = value;
|
|
||||||
}),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
TextFormField(maxLines: null, initialValue: command, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令内容"), onChanged: (value) {
|
|
||||||
command = value;
|
|
||||||
}),
|
|
||||||
])), actions: [
|
|
||||||
TextButton(onPressed:() {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("取消")),
|
|
||||||
TextButton(onPressed:() async {
|
|
||||||
await Util.setCurrentProp("commands", Util.getCurrentProp("commands")
|
|
||||||
..add({"name": name, "command": command}));
|
|
||||||
setState(() {});
|
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("添加")),
|
|
||||||
]);
|
|
||||||
},);
|
|
||||||
}, onLongPress: () {
|
|
||||||
showDialog(context: context, builder: (context) {
|
|
||||||
return AlertDialog(content: const Text("是否重置所有快捷指令?"), actions: [
|
|
||||||
TextButton(onPressed:() {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("取消")),
|
|
||||||
TextButton(onPressed:() async {
|
|
||||||
await Util.setCurrentProp("commands", D.commands);
|
|
||||||
setState(() {});
|
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("是")),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}, child: const Text("添加快捷指令")))),
|
|
||||||
Padding(padding: const EdgeInsets.all(8), child: Card(child: Padding(padding: const EdgeInsets.all(8), child:
|
Padding(padding: const EdgeInsets.all(8), child: Card(child: Padding(padding: const EdgeInsets.all(8), child:
|
||||||
Column(children: [
|
Column(children: [
|
||||||
ExpansionPanelList(
|
const SettingPage(),
|
||||||
elevation: 1,
|
SizedBox.fromSize(size: const Size.square(8)),
|
||||||
expandedHeaderPadding: const EdgeInsets.all(0),
|
const InfoPage()
|
||||||
expansionCallback: (panelIndex, isExpanded) {
|
])
|
||||||
setState(() {
|
)))
|
||||||
_expandState[panelIndex] = isExpanded;
|
|
||||||
});
|
|
||||||
},children: [
|
|
||||||
ExpansionPanel(
|
|
||||||
isExpanded: _expandState[0],
|
|
||||||
headerBuilder: ((context, isExpanded) {
|
|
||||||
return const ListTile(title: Text("高级设置"), subtitle: Text("修改后重启生效"));
|
|
||||||
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
|
||||||
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("name"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "容器名称"), onChanged: (value) async {
|
|
||||||
await Util.setCurrentProp("name", value);
|
|
||||||
setState(() {});
|
|
||||||
}),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("boot"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "启动命令"), onChanged: (value) async {
|
|
||||||
await Util.setCurrentProp("boot", value);
|
|
||||||
}),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("vnc"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "vnc启动命令"), onChanged: (value) async {
|
|
||||||
await Util.setCurrentProp("vnc", value);
|
|
||||||
}),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
TextFormField(maxLines: null, initialValue: Util.getCurrentProp("vncUrl"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "网页跳转地址"), onChanged: (value) async {
|
|
||||||
await Util.setCurrentProp("vncUrl", value);
|
|
||||||
}),
|
|
||||||
],))),
|
|
||||||
ExpansionPanel(
|
|
||||||
isExpanded: _expandState[1],
|
|
||||||
headerBuilder: ((context, isExpanded) {
|
|
||||||
return const ListTile(title: Text("全局设置"), subtitle: Text("在这里关广告、开启终端编辑"));
|
|
||||||
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
|
||||||
TextFormField(autovalidateMode: AutovalidateMode.onUserInteraction, initialValue: (Util.getGlobal("termMaxLines") as int).toString(), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "终端最大行数(重启软件生效)"), readOnly: Util.shouldWatchAds(G.adsRequired["changeTermMaxLines"]!),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onTap: () {
|
|
||||||
if (Util.shouldWatchAds(G.adsRequired["changeTermMaxLines"]!)) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("观看六次视频广告永久解锁><"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
validator: (value) {
|
|
||||||
return Util.validateBetween(value, 1024, 2147483647, () async {
|
|
||||||
await G.prefs.setInt("termMaxLines", int.parse(value!));
|
|
||||||
});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(16)),
|
|
||||||
TextFormField(autovalidateMode: AutovalidateMode.onUserInteraction, initialValue: (Util.getGlobal("defaultAudioPort") as int).toString(), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "pulseaudio接收端口"),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
validator: (value) {
|
|
||||||
return Util.validateBetween(value, 0, 65535, () async {
|
|
||||||
await G.prefs.setInt("defaultAudioPort", int.parse(value!));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
),
|
|
||||||
SizedBox.fromSize(size: const Size.square(16)),
|
|
||||||
SwitchListTile(title: const Text("关闭横幅广告"), value: Util.getGlobal("isBannerAdsClosed") as bool, onChanged:(value) {
|
|
||||||
if (value && Util.shouldWatchAds(G.adsRequired["closeBannerAds"]!)) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("观看五次视频广告永久解锁><"))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
G.prefs.setBool("isBannerAdsClosed", value);
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
SwitchListTile(title: const Text("启用终端"), value: Util.getGlobal("isTerminalWriteEnabled") as bool, onChanged:(value) {
|
|
||||||
if (value && Util.shouldWatchAds(G.adsRequired["enableTerminalWrite"]!)) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: const Text("观看两次视频广告永久解锁><"), action: SnackBarAction(label: "啊?", onPressed: () {
|
|
||||||
G.prefs.setBool("isTerminalWriteEnabled", value);
|
|
||||||
setState(() {});
|
|
||||||
},))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
G.prefs.setBool("isTerminalWriteEnabled", value);
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
SwitchListTile(title: const Text("启用终端小键盘"), value: Util.getGlobal("isTerminalCommandsEnabled") as bool, onChanged:(value) {
|
|
||||||
if (value && Util.shouldWatchAds(G.adsRequired["enableTerminalCommands"]!)) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("观看三次视频广告永久解锁><"))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
G.prefs.setBool("isTerminalCommandsEnabled", value);
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
SwitchListTile(title: const Text("终端粘滞键"), value: Util.getGlobal("isStickyKey") as bool, onChanged:(value) {
|
|
||||||
G.prefs.setBool("isStickyKey", value);
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
SwitchListTile(title: const Text("开启时启动图形界面"), value: Util.getGlobal("autoLaunchVnc") as bool, onChanged:(value) {
|
|
||||||
G.prefs.setBool("autoLaunchVnc", value);
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
],))),
|
|
||||||
ExpansionPanel(
|
|
||||||
isExpanded: _expandState[2],
|
|
||||||
headerBuilder: ((context, isExpanded) {
|
|
||||||
return const ListTile(title: Text("相机推流"), subtitle: Text("实验性功能"));
|
|
||||||
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
|
||||||
const Text("成功启动推流后可以点击快捷指令\"拉流测试\"并前往图形界面查看效果。\n注意这并不能为系统创建一个虚拟相机;\n另外使用相机是高耗电行为,不用时需及时关闭。"),
|
|
||||||
const SizedBox.square(dimension: 16),
|
|
||||||
Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: [
|
|
||||||
OutlinedButton(style: commandButtonStyle, child: const Text("申请相机权限"), onPressed: () {
|
|
||||||
Permission.camera.request();
|
|
||||||
}),
|
|
||||||
OutlinedButton(style: commandButtonStyle, child: const Text("申请麦克风权限"), onPressed: () {
|
|
||||||
Permission.microphone.request();
|
|
||||||
}),
|
|
||||||
OutlinedButton(style: commandButtonStyle, child: const Text("查看输出"), onPressed: () {
|
|
||||||
if (G.streamingOutput == "") {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("无输出"))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showDialog(context: context, builder: (context) {
|
|
||||||
return AlertDialog(content: SingleChildScrollView(child:
|
|
||||||
Text(G.streamingOutput)), actions: [
|
|
||||||
TextButton(onPressed:() {
|
|
||||||
FlutterClipboard.copy(G.streamingOutput).then(( value ) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("已复制")));
|
|
||||||
});
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("复制")),
|
|
||||||
TextButton(onPressed:() {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}, child: const Text("取消")),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
const SizedBox.square(dimension: 16),
|
|
||||||
TextFormField(maxLines: null, initialValue: Util.getGlobal("defaultFFmpegCommand") as String, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "ffmpeg推流命令"), readOnly: Util.shouldWatchAds(G.adsRequired["changeFFmpegCommand"]!),
|
|
||||||
onTap: () {
|
|
||||||
if (Util.shouldWatchAds(G.adsRequired["changeFFmpegCommand"]!)) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("观看八次视频广告永久解锁><"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onChanged: (value) async {
|
|
||||||
await G.prefs.setString("defaultFFmpegCommand", value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox.square(dimension: 16),
|
|
||||||
SwitchListTile(title: const Text("启动推流服务器"), subtitle: const Text("mediamtx"), value: G.isStreamServerStarted, onChanged:(value) {
|
|
||||||
switch (value) {
|
|
||||||
case true: {
|
|
||||||
G.streamServerPty = Pty.start("/system/bin/sh");
|
|
||||||
G.streamServerPty.write(const Utf8Encoder().convert("${G.dataPath}/bin/mediamtx ${G.dataPath}/bin/mediamtx.yml & pid=\$(echo \$!)\n"));
|
|
||||||
G.streamServerPty.exitCode.then((value) {
|
|
||||||
G.isStreamServerStarted = false;
|
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case false: {
|
|
||||||
G.streamServerPty.write(const Utf8Encoder().convert("kill \$pid\nexit\n"));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
G.isStreamServerStarted = value;
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
SwitchListTile(title: const Text("启动推流"), value: G.isStreaming, onChanged:(value) {
|
|
||||||
switch (value) {
|
|
||||||
case true: {
|
|
||||||
FFmpegKit.execute(Util.getGlobal("defaultFFmpegCommand") as String).then((session) {
|
|
||||||
session.getOutput().then((value) async {
|
|
||||||
G.isStreaming = false;
|
|
||||||
G.streamingOutput = value??"";
|
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case false: {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
G.isStreaming = value;
|
|
||||||
setState(() {});
|
|
||||||
},),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8))
|
|
||||||
],))),
|
|
||||||
ExpansionPanel(
|
|
||||||
isExpanded: _expandState[3],
|
|
||||||
headerBuilder: ((context, isExpanded) {
|
|
||||||
return const ListTile(title: Text("文件访问"));
|
|
||||||
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
|
||||||
const Text("通过这里获取更多文件权限,以实现对特殊目录的访问。"),
|
|
||||||
SizedBox.fromSize(size: const Size.square(16)),
|
|
||||||
Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: [
|
|
||||||
OutlinedButton(style: commandButtonStyle, child: const Text("申请存储权限"), onPressed: () {
|
|
||||||
Permission.storage.request();
|
|
||||||
}),
|
|
||||||
OutlinedButton(style: commandButtonStyle, child: const Text("申请所有文件访问权限"), onPressed: () {
|
|
||||||
Permission.manageExternalStorage.request();
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
SizedBox.fromSize(size: const Size.square(16)),
|
|
||||||
],))),
|
|
||||||
ExpansionPanel(
|
|
||||||
isExpanded: _expandState[4],
|
|
||||||
headerBuilder: ((context, isExpanded) {
|
|
||||||
return const ListTile(title: Text("广告记录"), subtitle: Text("在这里看广告"));
|
|
||||||
}), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
|
|
||||||
OutlinedButton(child: const Text("看一个广告"), onPressed: () {
|
|
||||||
if (AdManager.placements[AdManager.rewardedVideoAdPlacementId]!) {
|
|
||||||
AdManager.showAd(AdManager.rewardedVideoAdPlacementId, () {
|
|
||||||
final bonus = Util.getRandomBonus();
|
|
||||||
Util.applyBonus(bonus);
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("你获得了 ${bonus["name"]}*${bonus["amount"]}"))
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
|
|
||||||
});
|
|
||||||
}, () {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("已经看5个广告了, 今天也非常感谢><"))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox.square(dimension: 8),
|
|
||||||
Text(Util.getGlobal("adsBonus").map((element) {
|
|
||||||
final e = jsonDecode(element);
|
|
||||||
return e["amount"]==0?"":"${e["name"]}*${e["amount"]}\n";
|
|
||||||
}).join())
|
|
||||||
],))),
|
|
||||||
],),
|
|
||||||
SizedBox.fromSize(size: const Size.square(8)),
|
|
||||||
const InfoPage()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
))
|
|
||||||
,)
|
|
||||||
]
|
]
|
||||||
)))
|
)))
|
||||||
)][pageIndex],
|
)]);
|
||||||
transitionBuilder: (child, animation) {
|
}))]):const LoadingPage(),
|
||||||
return FadeTransition(opacity: animation, child: child);
|
bottomNavigationBar: ValueListenableBuilder(valueListenable: G.pageIndex, builder:(context, value, child) {
|
||||||
},))]):const LoadingPage(),
|
return Visibility(visible: isLoadingComplete,
|
||||||
bottomNavigationBar: Visibility(visible: isLoadingComplete,
|
child: BottomNavigationBar(currentIndex: G.pageIndex.value,
|
||||||
child: BottomNavigationBar(currentIndex: pageIndex,
|
onTap: (index) {
|
||||||
onTap: (index) {
|
G.pageIndex.value = index;
|
||||||
setState(() {
|
},
|
||||||
pageIndex = index;
|
items: const [
|
||||||
});
|
BottomNavigationBarItem(icon: Icon(Icons.monitor), label: "终端"),
|
||||||
},
|
BottomNavigationBarItem(icon: Icon(Icons.video_settings), label: "控制"),
|
||||||
items: const [
|
],
|
||||||
BottomNavigationBarItem(icon: Icon(Icons.monitor), label: "终端"),
|
)
|
||||||
BottomNavigationBarItem(icon: Icon(Icons.video_settings), label: "控制"),
|
);}),
|
||||||
],
|
floatingActionButton: ValueListenableBuilder(valueListenable: G.pageIndex, builder:(context, value, child) {
|
||||||
)
|
return Visibility(visible: isLoadingComplete && (value == 0),
|
||||||
),
|
child: FloatingActionButton(
|
||||||
floatingActionButton: Visibility(visible: isLoadingComplete && (pageIndex == 0),
|
onPressed: () => Workflow.launchBrowser(),
|
||||||
child: FloatingActionButton(
|
tooltip: "进入图形界面",
|
||||||
onPressed: () => Workflow.launchBrowser(),
|
child: const Icon(Icons.play_arrow),
|
||||||
tooltip: "进入图形界面",
|
),
|
||||||
child: const Icon(Icons.play_arrow),
|
);
|
||||||
),
|
}), // This trailing comma makes auto-formatting nicer for build methods.
|
||||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,16 +132,16 @@ class Util {
|
|||||||
//返回单个G.bonusTable定义的item
|
//返回单个G.bonusTable定义的item
|
||||||
static Map<String, dynamic> getRandomBonus() {
|
static Map<String, dynamic> getRandomBonus() {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
final totalWeight = G.bonusTable.fold(0.0, (sum, item) => sum + item['weight']);
|
final totalWeight = D.bonusTable.fold(0.0, (sum, item) => sum + item['weight']);
|
||||||
final randomIndex = random.nextDouble() * totalWeight;
|
final randomIndex = random.nextDouble() * totalWeight;
|
||||||
var cumulativeWeight = 0.0;
|
var cumulativeWeight = 0.0;
|
||||||
for (final item in G.bonusTable) {
|
for (final item in D.bonusTable) {
|
||||||
cumulativeWeight += item['weight'];
|
cumulativeWeight += item['weight'];
|
||||||
if (randomIndex <= cumulativeWeight) {
|
if (randomIndex <= cumulativeWeight) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return G.bonusTable[0];
|
return D.bonusTable[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
//由getRandomBonus返回的数据
|
//由getRandomBonus返回的数据
|
||||||
@@ -163,7 +163,7 @@ class Util {
|
|||||||
|
|
||||||
//根据已看广告量判断是否应该继续看广告
|
//根据已看广告量判断是否应该继续看广告
|
||||||
static bool shouldWatchAds(int expectNum) {
|
static bool shouldWatchAds(int expectNum) {
|
||||||
return ((Util.getGlobal("adsWatchedTotal") as int) < expectNum) && ((Util.getGlobal("vip") as int) < 1) && ((Util.getGlobal("adsWatchedToday") as int) < G.adsRequired["unlockToday"]!) && (G.adsWatchedThisTime < G.adsRequired["unlockOnce"]!);
|
return ((Util.getGlobal("adsWatchedTotal") as int) < expectNum) && ((Util.getGlobal("vip") as int) < 1) && ((Util.getGlobal("adsWatchedToday") as int) < D.adsRequired["unlockToday"]!) && (G.adsWatchedThisTime < D.adsRequired["unlockOnce"]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
//限定字符串在min和max之间, 给文本框的validator
|
//限定字符串在min和max之间, 给文本框的validator
|
||||||
@@ -230,7 +230,7 @@ class VirtualKeyboard extends TerminalInputHandler with ChangeNotifier {
|
|||||||
shift: event.shift || _shift,
|
shift: event.shift || _shift,
|
||||||
alt: event.alt || _alt,
|
alt: event.alt || _alt,
|
||||||
));
|
));
|
||||||
G.maybeCtrlJ = event.key.name == "keyJ";
|
G.maybeCtrlJ = event.key.name == "keyJ"; //这个是为了稍后区分按键到底是Enter还是Ctrl+J
|
||||||
if (!(Util.getGlobal("isStickyKey") as bool)) {
|
if (!(Util.getGlobal("isStickyKey") as bool)) {
|
||||||
G.keyboard.ctrl = false;
|
G.keyboard.ctrl = false;
|
||||||
G.keyboard.shift = false;
|
G.keyboard.shift = false;
|
||||||
@@ -352,7 +352,7 @@ class D {
|
|||||||
{"name":"拉流测试", "command":"ffplay rtsp://127.0.0.1:8554/stream &"},
|
{"name":"拉流测试", "command":"ffplay rtsp://127.0.0.1:8554/stream &"},
|
||||||
{"name":"关机", "command":"stopvnc\nexit\nexit"},
|
{"name":"关机", "command":"stopvnc\nexit\nexit"},
|
||||||
{"name":"???", "command":"timeout 8 cmatrix"}];
|
{"name":"???", "command":"timeout 8 cmatrix"}];
|
||||||
//默认快捷指令
|
//默认小键盘
|
||||||
static const termCommands = [
|
static const termCommands = [
|
||||||
{"name": "Esc", "key": TerminalKey.escape},
|
{"name": "Esc", "key": TerminalKey.escape},
|
||||||
{"name": "Tab", "key": TerminalKey.tab},
|
{"name": "Tab", "key": TerminalKey.tab},
|
||||||
@@ -378,27 +378,6 @@ class D {
|
|||||||
{"name": "F11", "key": TerminalKey.f11},
|
{"name": "F11", "key": TerminalKey.f11},
|
||||||
{"name": "F12", "key": TerminalKey.f12},
|
{"name": "F12", "key": TerminalKey.f12},
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
// Global variables
|
|
||||||
class G {
|
|
||||||
static late final String dataPath;
|
|
||||||
static Pty? audioPty;
|
|
||||||
static late WebViewController controller;
|
|
||||||
static late BuildContext homePageStateContext;
|
|
||||||
static late int currentContainer; //目前运行第几个容器
|
|
||||||
static late Map<int, TermPty> termPtys; //为容器<int>存放TermPty数据
|
|
||||||
static late AdManager ads; //广告实例
|
|
||||||
static late VirtualKeyboard keyboard; //存储ctrl, shift, alt状态
|
|
||||||
static bool maybeCtrlJ = false; //为了区分按下的ctrl+J和enter而准备的变量
|
|
||||||
static double termFontScale = 1; //终端字体大小,存储为G.prefs的termFontScale
|
|
||||||
static int adsWatchedThisTime = 0; //本次启动应用看的广告数
|
|
||||||
static bool isStreamServerStarted = false;
|
|
||||||
static bool isStreaming = false;
|
|
||||||
static int? streamingId;
|
|
||||||
static String streamingOutput = "";
|
|
||||||
static late Pty streamServerPty;
|
|
||||||
|
|
||||||
|
|
||||||
//看广告可以获得的奖励。
|
//看广告可以获得的奖励。
|
||||||
//weight抽奖权重,singleUse使用一次花费的数量,amount抽中可以获得的数量
|
//weight抽奖权重,singleUse使用一次花费的数量,amount抽中可以获得的数量
|
||||||
@@ -426,6 +405,44 @@ class G {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static final ButtonStyle commandButtonStyle = OutlinedButton.styleFrom(
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
minimumSize: const Size(0, 0),
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
static final ButtonStyle controlButtonStyle = OutlinedButton.styleFrom(
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
side: const BorderSide(color: Color(0x1F000000)),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
minimumSize: const Size(0, 0),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global variables
|
||||||
|
class G {
|
||||||
|
static late final String dataPath;
|
||||||
|
static Pty? audioPty;
|
||||||
|
static late WebViewController controller;
|
||||||
|
static late BuildContext homePageStateContext;
|
||||||
|
static late int currentContainer; //目前运行第几个容器
|
||||||
|
static late Map<int, TermPty> termPtys; //为容器<int>存放TermPty数据
|
||||||
|
static late AdManager ads; //广告实例
|
||||||
|
static late VirtualKeyboard keyboard; //存储ctrl, shift, alt状态
|
||||||
|
static bool maybeCtrlJ = false; //为了区分按下的ctrl+J和enter而准备的变量
|
||||||
|
static ValueNotifier<double> termFontScale = ValueNotifier(1); //终端字体大小,存储为G.prefs的termFontScale
|
||||||
|
static int adsWatchedThisTime = 0; //本次启动应用看的广告数
|
||||||
|
static bool isStreamServerStarted = false;
|
||||||
|
static bool isStreaming = false;
|
||||||
|
static int? streamingId;
|
||||||
|
static String streamingOutput = "";
|
||||||
|
static late Pty streamServerPty;
|
||||||
|
static ValueNotifier<int> pageIndex = ValueNotifier(0); //主界面索引
|
||||||
|
|
||||||
|
|
||||||
static late SharedPreferences prefs;
|
static late SharedPreferences prefs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,18 +609,6 @@ done
|
|||||||
"vncUrl":"http://localhost:36082/vnc.html?host=localhost&port=36082&autoconnect=true&resize=remote&password=12345678",
|
"vncUrl":"http://localhost:36082/vnc.html?host=localhost&port=36082&autoconnect=true&resize=remote&password=12345678",
|
||||||
"commands":${jsonEncode(D.commands)}
|
"commands":${jsonEncode(D.commands)}
|
||||||
}"""]);
|
}"""]);
|
||||||
// await G.prefs.setStringList("adsBonus", []);
|
|
||||||
// await G.prefs.setInt("adsWatchedTotal", 0);
|
|
||||||
// await G.prefs.setBool("isTerminalCommandsEnabled", false);
|
|
||||||
// await G.prefs.setBool("isTerminalWriteEnabled", false);
|
|
||||||
// await G.prefs.setBool("isBannerAdsClosed", false);
|
|
||||||
// await G.prefs.setBool("autoLaunchVnc", true);
|
|
||||||
// await G.prefs.setInt("defaultAudioPort", 4718);
|
|
||||||
// await G.prefs.setInt("defaultContainer", 0);
|
|
||||||
// await G.prefs.setInt("termMaxLines", 4095);
|
|
||||||
// await G.prefs.setDouble("termFontScale", 1);
|
|
||||||
// await G.prefs.setInt("vip", 0);
|
|
||||||
// await G.prefs.setBool("isStickyKey", true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> initData() async {
|
static Future<void> initData() async {
|
||||||
@@ -629,24 +634,24 @@ done
|
|||||||
}
|
}
|
||||||
G.currentContainer = Util.getGlobal("defaultContainer") as int;
|
G.currentContainer = Util.getGlobal("defaultContainer") as int;
|
||||||
|
|
||||||
G.termFontScale = Util.getGlobal("termFontScale") as double;
|
G.termFontScale.value = Util.getGlobal("termFontScale") as double;
|
||||||
|
|
||||||
G.controller = WebViewController()..setJavaScriptMode(JavaScriptMode.unrestricted);
|
G.controller = WebViewController()..setJavaScriptMode(JavaScriptMode.unrestricted);
|
||||||
|
|
||||||
//恢复临时开启的功能
|
//恢复临时开启的功能
|
||||||
if (Util.shouldWatchAds(G.adsRequired["changeFFmpegCommand"]!)) {
|
if (Util.shouldWatchAds(D.adsRequired["changeFFmpegCommand"]!)) {
|
||||||
await G.prefs.remove("defaultFFmpegCommand");
|
await G.prefs.remove("defaultFFmpegCommand");
|
||||||
}
|
}
|
||||||
if (Util.shouldWatchAds(G.adsRequired["changeTermMaxLines"]!)) {
|
if (Util.shouldWatchAds(D.adsRequired["changeTermMaxLines"]!)) {
|
||||||
await G.prefs.setInt("termMaxLines", 4095);
|
await G.prefs.setInt("termMaxLines", 4095);
|
||||||
}
|
}
|
||||||
if (Util.shouldWatchAds(G.adsRequired["closeBannerAds"]!)) {
|
if (Util.shouldWatchAds(D.adsRequired["closeBannerAds"]!)) {
|
||||||
await G.prefs.setBool("isBannerAdsClosed", false);
|
await G.prefs.setBool("isBannerAdsClosed", false);
|
||||||
}
|
}
|
||||||
if (Util.shouldWatchAds(G.adsRequired["enableTerminalWrite"]!)) {
|
if (Util.shouldWatchAds(D.adsRequired["enableTerminalWrite"]!)) {
|
||||||
await G.prefs.setBool("isTerminalWriteEnabled", false);
|
await G.prefs.setBool("isTerminalWriteEnabled", false);
|
||||||
}
|
}
|
||||||
if (Util.shouldWatchAds(G.adsRequired["enableTerminalCommands"]!)) {
|
if (Util.shouldWatchAds(D.adsRequired["enableTerminalCommands"]!)) {
|
||||||
await G.prefs.setBool("isTerminalCommandsEnabled", false);
|
await G.prefs.setBool("isTerminalCommandsEnabled", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user