| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- import 'dart:async';
- import 'dart:convert';
- import 'dart:typed_data';
- import 'package:flutter/material.dart';
- import 'package:web_socket_channel/web_socket_channel.dart';
- // ─── Remote Screen ────────────────────────────────────────────────────────────
- class RemoteScreen extends StatefulWidget {
- final String host;
- final int port;
- const RemoteScreen({super.key, required this.host, required this.port});
- @override
- State<RemoteScreen> createState() => _RemoteScreenState();
- }
- class _RemoteScreenState extends State<RemoteScreen> {
- late WebSocketChannel _channel;
- StreamSubscription? _sub;
- int _current = 0;
- int _total = 0;
- Uint8List? _slideImage;
- bool _connected = false;
- bool _presentationOpen = true; // assume open on Windows; Linux server will correct
- String? _error;
- @override
- void initState() {
- super.initState();
- _connect();
- }
- void _connect() {
- final uri = Uri.parse('ws://${widget.host}:${widget.port}');
- _channel = WebSocketChannel.connect(uri);
- setState(() {
- _connected = true;
- _error = null;
- });
- _sub = _channel.stream.listen(
- (data) {
- final msg = jsonDecode(data as String) as Map<String, dynamic>;
- _handleMessage(msg);
- },
- onError: (e) {
- setState(() {
- _connected = false;
- _error = 'Connection error: $e';
- });
- },
- onDone: () {
- setState(() {
- _connected = false;
- _error = 'Disconnected from server.';
- });
- },
- );
- }
- void _handleMessage(Map<String, dynamic> msg) {
- final event = msg['event'] as String?;
- if (event == 'slide') {
- setState(() {
- _current = (msg['current'] as num).toInt();
- _total = (msg['total'] as num).toInt();
- _presentationOpen = true;
- });
- } else if (event == 'image') {
- final b64 = msg['data'] as String?;
- if (b64 != null && b64.isNotEmpty) {
- setState(() => _slideImage = base64Decode(b64));
- }
- } else if (event == 'presentation') {
- final status = msg['status'] as String?;
- setState(() {
- _presentationOpen = status == 'opened';
- if (!_presentationOpen) {
- _slideImage = null;
- _current = 0;
- _total = 0;
- }
- });
- if (status == 'closed') {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Presentation closed — waiting for new file...')),
- );
- }
- } else if (event == 'error') {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(msg['message'] ?? 'Unknown error')),
- );
- }
- }
- void _send(String cmd) {
- if (!_connected) return;
- _channel.sink.add(jsonEncode({'cmd': cmd}));
- }
- @override
- void dispose() {
- _sub?.cancel();
- _channel.sink.close();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(
- title: Text('PPT Remote — ${widget.host}'),
- actions: [
- Icon(
- _connected ? Icons.wifi : Icons.wifi_off,
- color: _connected ? Colors.greenAccent : Colors.redAccent,
- ),
- const SizedBox(width: 12),
- ],
- ),
- body: Column(
- children: [
- // Slide preview
- Expanded(
- child: _slideImage != null
- ? Padding(
- padding: const EdgeInsets.all(16),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(12),
- child: Image.memory(
- _slideImage!,
- fit: BoxFit.contain,
- ),
- ),
- )
- : Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- _presentationOpen ? Icons.slideshow : Icons.hourglass_empty,
- size: 80,
- color: Colors.white24,
- ),
- const SizedBox(height: 12),
- Text(
- _presentationOpen
- ? (_error ?? 'No slide preview')
- : 'Waiting for presentation\nto be opened on server...',
- style: const TextStyle(color: Colors.white38),
- textAlign: TextAlign.center,
- ),
- if (!_presentationOpen) ...[
- const SizedBox(height: 16),
- const SizedBox(
- width: 24,
- height: 24,
- child: CircularProgressIndicator(strokeWidth: 2),
- ),
- ],
- ],
- ),
- ),
- ),
- // Slide counter
- if (_total > 0)
- Text(
- 'Slide $_current / $_total',
- style: Theme.of(context).textTheme.headlineSmall,
- ),
- // Progress bar
- if (_total > 0)
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
- child: LinearProgressIndicator(
- value: _current / _total,
- minHeight: 6,
- borderRadius: BorderRadius.circular(4),
- ),
- ),
- // Controls
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
- child: Column(
- children: [
- // Navigation row
- Row(
- children: [
- Expanded(
- child: _NavButton(
- icon: Icons.arrow_back_ios_new,
- label: 'Prev',
- onTap: () => _send('prev'),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: _NavButton(
- icon: Icons.arrow_forward_ios,
- label: 'Next',
- onTap: () => _send('next'),
- ),
- ),
- ],
- ),
- const SizedBox(height: 12),
- // Slideshow controls row
- Row(
- children: [
- Expanded(
- child: _NavButton(
- icon: Icons.play_arrow,
- label: 'Start Show',
- color: Colors.green,
- onTap: () => _send('start'),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: _NavButton(
- icon: Icons.stop,
- label: 'End Show',
- color: Colors.red,
- onTap: () => _send('end'),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ],
- ),
- );
- }
- }
- // ─── Nav Button ───────────────────────────────────────────────────────────────
- class _NavButton extends StatelessWidget {
- final IconData icon;
- final String label;
- final VoidCallback onTap;
- final Color? color;
- const _NavButton({
- required this.icon,
- required this.label,
- required this.onTap,
- this.color,
- });
- @override
- Widget build(BuildContext context) {
- return Material(
- color: (color ?? Theme.of(context).colorScheme.primary).withOpacity(0.15),
- borderRadius: BorderRadius.circular(16),
- child: InkWell(
- borderRadius: BorderRadius.circular(16),
- onTap: onTap,
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 20),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, size: 32, color: color ?? Theme.of(context).colorScheme.primary),
- const SizedBox(height: 6),
- Text(label, style: TextStyle(color: color ?? Theme.of(context).colorScheme.primary)),
- ],
- ),
- ),
- ),
- );
- }
- }
|