slide_controller.dart 7.3 KB

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