slide_controller.dart 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'dart:typed_data';
  4. import 'package:flutter/material.dart';
  5. import 'package:web_socket_channel/web_socket_channel.dart';
  6. // ─── Remote Screen ────────────────────────────────────────────────────────────
  7. class RemoteScreen extends StatefulWidget {
  8. final String host;
  9. final int port;
  10. const RemoteScreen({super.key, required this.host, required this.port});
  11. @override
  12. State<RemoteScreen> createState() => _RemoteScreenState();
  13. }
  14. class _RemoteScreenState extends State<RemoteScreen> {
  15. late WebSocketChannel _channel;
  16. StreamSubscription? _sub;
  17. int _current = 0;
  18. int _total = 0;
  19. Uint8List? _slideImage;
  20. bool _connected = false;
  21. bool _presentationOpen = true; // assume open on Windows; Linux server will correct
  22. String? _error;
  23. @override
  24. void initState() {
  25. super.initState();
  26. _connect();
  27. }
  28. void _connect() {
  29. final uri = Uri.parse('ws://${widget.host}:${widget.port}');
  30. _channel = WebSocketChannel.connect(uri);
  31. setState(() {
  32. _connected = true;
  33. _error = null;
  34. });
  35. _sub = _channel.stream.listen(
  36. (data) {
  37. final msg = jsonDecode(data as String) as Map<String, dynamic>;
  38. _handleMessage(msg);
  39. },
  40. onError: (e) {
  41. setState(() {
  42. _connected = false;
  43. _error = 'Connection error: $e';
  44. });
  45. },
  46. onDone: () {
  47. setState(() {
  48. _connected = false;
  49. _error = 'Disconnected from server.';
  50. });
  51. },
  52. );
  53. }
  54. void _handleMessage(Map<String, dynamic> msg) {
  55. final event = msg['event'] as String?;
  56. if (event == 'slide') {
  57. setState(() {
  58. _current = (msg['current'] as num).toInt();
  59. _total = (msg['total'] as num).toInt();
  60. _presentationOpen = true;
  61. });
  62. } else if (event == 'image') {
  63. final b64 = msg['data'] as String?;
  64. if (b64 != null && b64.isNotEmpty) {
  65. setState(() => _slideImage = base64Decode(b64));
  66. }
  67. } else if (event == 'presentation') {
  68. final status = msg['status'] as String?;
  69. setState(() {
  70. _presentationOpen = status == 'opened';
  71. if (!_presentationOpen) {
  72. _slideImage = null;
  73. _current = 0;
  74. _total = 0;
  75. }
  76. });
  77. if (status == 'closed') {
  78. ScaffoldMessenger.of(context).showSnackBar(
  79. const SnackBar(content: Text('Presentation closed — waiting for new file...')),
  80. );
  81. }
  82. } else if (event == 'error') {
  83. ScaffoldMessenger.of(context).showSnackBar(
  84. SnackBar(content: Text(msg['message'] ?? 'Unknown error')),
  85. );
  86. }
  87. }
  88. void _send(String cmd) {
  89. if (!_connected) return;
  90. _channel.sink.add(jsonEncode({'cmd': cmd}));
  91. }
  92. @override
  93. void dispose() {
  94. _sub?.cancel();
  95. _channel.sink.close();
  96. super.dispose();
  97. }
  98. @override
  99. Widget build(BuildContext context) {
  100. return Scaffold(
  101. appBar: AppBar(
  102. title: Text('PPT Remote — ${widget.host}'),
  103. actions: [
  104. Icon(
  105. _connected ? Icons.wifi : Icons.wifi_off,
  106. color: _connected ? Colors.greenAccent : Colors.redAccent,
  107. ),
  108. const SizedBox(width: 12),
  109. ],
  110. ),
  111. body: Column(
  112. children: [
  113. // Slide preview
  114. Expanded(
  115. child: _slideImage != null
  116. ? Padding(
  117. padding: const EdgeInsets.all(16),
  118. child: ClipRRect(
  119. borderRadius: BorderRadius.circular(12),
  120. child: Image.memory(
  121. _slideImage!,
  122. fit: BoxFit.contain,
  123. ),
  124. ),
  125. )
  126. : Center(
  127. child: Column(
  128. mainAxisSize: MainAxisSize.min,
  129. children: [
  130. Icon(
  131. _presentationOpen ? Icons.slideshow : Icons.hourglass_empty,
  132. size: 80,
  133. color: Colors.white24,
  134. ),
  135. const SizedBox(height: 12),
  136. Text(
  137. _presentationOpen
  138. ? (_error ?? 'No slide preview')
  139. : 'Waiting for presentation\nto be opened on server...',
  140. style: const TextStyle(color: Colors.white38),
  141. textAlign: TextAlign.center,
  142. ),
  143. if (!_presentationOpen) ...[
  144. const SizedBox(height: 16),
  145. const SizedBox(
  146. width: 24,
  147. height: 24,
  148. child: CircularProgressIndicator(strokeWidth: 2),
  149. ),
  150. ],
  151. ],
  152. ),
  153. ),
  154. ),
  155. // Slide counter
  156. if (_total > 0)
  157. Text(
  158. 'Slide $_current / $_total',
  159. style: Theme.of(context).textTheme.headlineSmall,
  160. ),
  161. // Progress bar
  162. if (_total > 0)
  163. Padding(
  164. padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
  165. child: LinearProgressIndicator(
  166. value: _current / _total,
  167. minHeight: 6,
  168. borderRadius: BorderRadius.circular(4),
  169. ),
  170. ),
  171. // Controls
  172. Padding(
  173. padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
  174. child: Column(
  175. children: [
  176. // Navigation row
  177. Row(
  178. children: [
  179. Expanded(
  180. child: _NavButton(
  181. icon: Icons.arrow_back_ios_new,
  182. label: 'Prev',
  183. onTap: () => _send('prev'),
  184. ),
  185. ),
  186. const SizedBox(width: 12),
  187. Expanded(
  188. child: _NavButton(
  189. icon: Icons.arrow_forward_ios,
  190. label: 'Next',
  191. onTap: () => _send('next'),
  192. ),
  193. ),
  194. ],
  195. ),
  196. const SizedBox(height: 12),
  197. // Slideshow controls row
  198. Row(
  199. children: [
  200. Expanded(
  201. child: _NavButton(
  202. icon: Icons.play_arrow,
  203. label: 'Start Show',
  204. color: Colors.green,
  205. onTap: () => _send('start'),
  206. ),
  207. ),
  208. const SizedBox(width: 12),
  209. Expanded(
  210. child: _NavButton(
  211. icon: Icons.stop,
  212. label: 'End Show',
  213. color: Colors.red,
  214. onTap: () => _send('end'),
  215. ),
  216. ),
  217. ],
  218. ),
  219. ],
  220. ),
  221. ),
  222. ],
  223. ),
  224. );
  225. }
  226. }
  227. // ─── Nav Button ───────────────────────────────────────────────────────────────
  228. class _NavButton extends StatelessWidget {
  229. final IconData icon;
  230. final String label;
  231. final VoidCallback onTap;
  232. final Color? color;
  233. const _NavButton({
  234. required this.icon,
  235. required this.label,
  236. required this.onTap,
  237. this.color,
  238. });
  239. @override
  240. Widget build(BuildContext context) {
  241. return Material(
  242. color: (color ?? Theme.of(context).colorScheme.primary).withOpacity(0.15),
  243. borderRadius: BorderRadius.circular(16),
  244. child: InkWell(
  245. borderRadius: BorderRadius.circular(16),
  246. onTap: onTap,
  247. child: Padding(
  248. padding: const EdgeInsets.symmetric(vertical: 20),
  249. child: Column(
  250. mainAxisSize: MainAxisSize.min,
  251. children: [
  252. Icon(icon, size: 32, color: color ?? Theme.of(context).colorScheme.primary),
  253. const SizedBox(height: 6),
  254. Text(label, style: TextStyle(color: color ?? Theme.of(context).colorScheme.primary)),
  255. ],
  256. ),
  257. ),
  258. ),
  259. );
  260. }
  261. }