diff --git a/assets/images/cry1.gif b/assets/images/cry1.gif new file mode 100644 index 0000000..c9b59ad Binary files /dev/null and b/assets/images/cry1.gif differ diff --git a/assets/images/cry2.gif b/assets/images/cry2.gif new file mode 100644 index 0000000..85b947b Binary files /dev/null and b/assets/images/cry2.gif differ diff --git a/assets/images/dance1.webp b/assets/images/dance1.webp new file mode 100644 index 0000000..3707aed Binary files /dev/null and b/assets/images/dance1.webp differ diff --git a/assets/images/dance2.gif b/assets/images/dance2.gif new file mode 100644 index 0000000..cc1d4a5 Binary files /dev/null and b/assets/images/dance2.gif differ diff --git a/lib/api/commands.dart b/lib/api/commands.dart index 9b9b861..dcde04f 100644 --- a/lib/api/commands.dart +++ b/lib/api/commands.dart @@ -1,16 +1,16 @@ import 'dart:convert'; -sealed class ConnectionEvent {} +sealed class PlayerConnectionEvent {} -class Connect extends ConnectionEvent { +class Connect extends PlayerConnectionEvent { final String uri; Connect(this.uri); } -class Disconnect extends ConnectionEvent {} +class Disconnect extends PlayerConnectionEvent {} -class Command extends ConnectionEvent { +class Command extends PlayerConnectionEvent { final String type; final Map value; diff --git a/lib/api/events.dart b/lib/api/events.dart index 2d4a8c7..25ad46e 100644 --- a/lib/api/events.dart +++ b/lib/api/events.dart @@ -1,7 +1,6 @@ -// TODO: handle typing and deserialization of events import 'package:flutter/foundation.dart'; -import '../state/player_state.dart'; +import 'package:gergle/state/player_state.dart'; // NOTE: see DEFAULT_PROPERTY_SUBSCRIPTIONS in the websocket API source for greg-ng. diff --git a/lib/main.dart b/lib/main.dart index c61f176..fe25d61 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'player_ui/main.dart'; import 'state/connection_state_bloc.dart'; import 'state/player_state_bloc.dart'; -import 'player_ui.dart'; void main() => runApp(const MyApp()); @@ -18,13 +18,22 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Gergle', + theme: ThemeData( + useMaterial3: true, + + // primaryColor: const Color(0x002244FF), + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.lightBlue, + brightness: Brightness.dark, + ), + ), home: MultiBlocProvider( providers: [ BlocProvider(create: (_) => connectionStateBloc), BlocProvider(create: (_) => playerStateBloc), ], - child: PlayerUi(), + child: const PlayerUi(), ), ); } -} \ No newline at end of file +} diff --git a/lib/player_ui.dart b/lib/player_ui.dart deleted file mode 100644 index ef796d2..0000000 --- a/lib/player_ui.dart +++ /dev/null @@ -1,414 +0,0 @@ -import 'dart:math' show max; -import 'dart:developer' show log; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:gergle/api/commands.dart'; -import 'package:gergle/api/events.dart'; -import 'state/connection_state_bloc.dart'; -import 'state/player_state_bloc.dart'; -import 'state/player_state.dart'; - -class PlayerUi extends StatelessWidget { - PlayerUi({ - super.key, - }); - - final TextEditingController _textController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: _buildAppBar(context), - body: Padding( - padding: const EdgeInsets.all(20), - child: BlocBuilder( - builder: (context, state) { - if (state == null) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - final playerState = state; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ReorderableListView.builder( - scrollDirection: Axis.vertical, - itemBuilder: (context, i) => - _buildPlaylistTile(context, playerState, i), - itemCount: playerState.playlist.length, - onReorder: (from, to) { - BlocProvider.of(context).add( - Command.playlistMove(from, to), - ); - - if (from < to) { - to -= 1; - } - final item = playerState.playlist.removeAt(from); - playerState.playlist.insert(to, item); - - BlocProvider.of(context).add( - PlaylistChange(playerState.playlist, local: true)); - }, - ), - ), - _buildInputBar(context, playerState), - ], - ); - }, - ), - ), - bottomNavigationBar: BlocBuilder( - builder: (context, state) { - if (state == null) { - return const SizedBox.shrink(); - } - - return _buildBottomBar(context, state); - }, - ), - ); - } - - AppBar _buildAppBar(BuildContext context) { - return AppBar( - title: const Text('Gergle'), - backgroundColor: Theme.of(context).primaryColor, - actions: [ - DropdownMenu( - leadingIcon: const Icon(Icons.storage), - dropdownMenuEntries: const >[ - DropdownMenuEntry( - label: 'Georg', - value: 'wss://georg.pvv.ntnu.no/ws', - ), - DropdownMenuEntry( - label: 'Brzeczyszczykiewicz', - value: 'wss://brzeczyszczykiewicz.pvv.ntnu.no/ws', - ), - if (kDebugMode) ...[ - DropdownMenuEntry( - label: 'Local 8009', - value: 'ws://localhost:8009/ws', - ), - ], - DropdownMenuEntry( - value: null, - label: 'Custom...', - ), - ], - onSelected: (value) async { - final connectionStateBloc = - BlocProvider.of(context); - value ??= await _askForServerUriMenu(context); - - if (value == null) { - return; - } - - connectionStateBloc.add(Connect(value)); - }, - ), - IconButton( - icon: const Icon(Icons.settings), - onPressed: () { - throw UnimplementedError(); - }, - ), - if (kDebugMode) ...[ - const SizedBox(width: 50), - ], - ], - ); - } - - Future _askForServerUriMenu(BuildContext context) async { - final textController = TextEditingController(); - - return await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return AlertDialog( - title: const Text('Enter server URI'), - content: TextField( - decoration: const InputDecoration( - labelText: 'Server URI', - ), - controller: textController, - onSubmitted: (value) { - Navigator.of(context).pop(value); - }, - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - final value = textController.text; - textController.dispose(); - Navigator.of(context).pop(value); - }, - child: const Text('Connect'), - ), - ], - ); - }, - ), - ); - } - - Widget _buildInputBar(BuildContext context, PlayerState playerState) { - return Row( - children: [ - Expanded( - child: TextField( - controller: _textController, - decoration: InputDecoration( - labelText: 'Add to playlist', - filled: true, - fillColor: const Color.fromARGB(10, 0, 0, 0), - border: OutlineInputBorder( - borderSide: - BorderSide(color: Theme.of(context).primaryColor, width: 2), - borderRadius: BorderRadius.circular(10), - ), - ), - onSubmitted: (value) { - BlocProvider.of(context) - .add(Command.load(value)); - _textController.clear(); - }, - ), - ), - IconButton( - icon: const Icon(Icons.send), - onPressed: () { - BlocProvider.of(context) - .add(Command.load(_textController.text)); - _textController.clear(); - }, - ), - IconButton( - icon: const Icon(Icons.playlist_add), - onPressed: () { - // TODO: popup menu for adding multiple links - throw UnimplementedError(); - }, - ), - ], - ); - } - - ListTile _buildPlaylistTile( - BuildContext context, - PlayerState playerState, - int i, - ) { - final item = playerState.playlist[i]; - return ListTile( - key: ValueKey(item.id), - title: Text(item.title ?? item.filename), - subtitle: Text(item.filename), - selected: item.current, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "${i + 1}.", - style: Theme.of(context).textTheme.titleLarge, - ), - IconButton( - icon: playerState.isPlaying && item.current - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow), - onPressed: () { - BlocProvider.of(context) - .add(Command.playlistGoto(i)); - }, - ), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () => - Clipboard.setData(ClipboardData(text: item.filename)), - ), - IconButton( - icon: const Icon(Icons.delete_forever), - color: Colors.redAccent, - onPressed: () { - BlocProvider.of(context) - .add(Command.playlistRemove(i)); - }, - ), - ], - ), - ); - } - - static String formatTime(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - - if (hours > 0) { - return '$hours:$minutes:$seconds'; - } else { - return '$minutes:$seconds'; - } - } - - Widget _buildBottomBar(BuildContext context, PlayerState playerState) { - // NOTE: slider throws if the value is over 100, so 99.999 is used to avoid - // hitting the limit with floating point errors. - double cachedPercent = playerState.cachedTimestamp != null - ? ((playerState.cachedTimestamp! / - max(playerState.duration.inMilliseconds, 0.00000000000001)) * - 1000 * - 99.999) - : 0.0; - if (cachedPercent > 100) { - cachedPercent = 0; - } - - return BottomAppBar( - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.skip_previous), - onPressed: () { - BlocProvider.of(context) - .add(Command.playlistPrevious()); - }, - ), - IconButton( - icon: playerState.isPlaying - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow), - onPressed: () { - BlocProvider.of(context) - .add(Command.togglePlayback()); - }, - ), - IconButton( - icon: const Icon(Icons.skip_next), - onPressed: () { - BlocProvider.of(context) - .add(Command.playlistNext()); - }, - ), - Expanded( - child: Flex( - direction: Axis.horizontal, - children: [ - Text( - formatTime( - Duration( - milliseconds: playerState.currentPercentPosition != null - ? (playerState.currentPercentPosition! * - playerState.duration.inMilliseconds * - 0.01) - .round() - : 0, - ), - ), - ), - // Text(((playerState.currentPercentPosition ?? 0.0) * - // 0.01 * - // playerState.duration) - // .toString()), - Expanded( - flex: 5, - child: Slider( - value: playerState.currentPercentPosition ?? 0, - max: 100.0, - secondaryTrackValue: cachedPercent, - onChanged: (value) { - log('Setting time to $value'); - BlocProvider.of(context) - .add(Command.time(value)); - }, - ), - ), - Text(formatTime(playerState.duration)), - // TODO: set minimum width for this slider - Expanded( - flex: 1, - child: Slider( - value: playerState.volume, - max: 130.0, - secondaryTrackValue: 100.0, - onChanged: (value) { - BlocProvider.of(context) - .add(Command.volume(value)); - }, - ), - ), - Text('${playerState.volume.round()}%'), - ], - ), - ), - // IconButton( - // icon: const Icon(Icons.subtitles), - // onPressed: () { - // throw UnimplementedError(); - // }, - // ), - PopupMenuButton( - icon: const Icon(Icons.subtitles), - enabled: playerState.subtitleTracks.isNotEmpty, - itemBuilder: (context) { - return playerState.subtitleTracks - .map((track) => PopupMenuItem( - value: track.id, - child: Text(track.title), - )) - .toList(); - }, - onSelected: (value) { - // TODO: add command for changing subtitle track - throw UnimplementedError(); - }, - ), - IconButton( - icon: const Icon(Icons.repeat), - isSelected: playerState.isLooping, - onPressed: () { - BlocProvider.of(context) - .add(Command.setLooping(!playerState.isLooping)); - }, - ), - IconButton( - icon: const Icon(Icons.shuffle), - onPressed: () { - BlocProvider.of(context) - .add(Command.shuffle()); - }, - ), - IconButton( - icon: const Icon(Icons.delete_forever), - onPressed: () { - BlocProvider.of(context) - .add(Command.playlistClear()); - }, - ), - ], - ), - ); - } -} diff --git a/lib/player_ui/app_bar.dart b/lib/player_ui/app_bar.dart new file mode 100644 index 0000000..173ea1f --- /dev/null +++ b/lib/player_ui/app_bar.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:gergle/api/commands.dart'; +import 'package:gergle/player_ui/main.dart'; +import 'package:gergle/state/connection_state_bloc.dart'; + +class PlayerUIAppBar{ + static AppBar appbar(BuildContext context) { + return AppBar( + title: const Text('Gergle'), + backgroundColor: Theme.of(context).primaryColor, + actions: [ + DropdownMenu( + leadingIcon: const Icon(Icons.storage), + dropdownMenuEntries: const >[ + DropdownMenuEntry( + label: 'Georg', + value: 'wss://georg.pvv.ntnu.no/ws', + ), + DropdownMenuEntry( + label: 'Brzeczyszczykiewicz', + value: 'wss://brzeczyszczykiewicz.pvv.ntnu.no/ws', + ), + if (kDebugMode) ...[ + DropdownMenuEntry( + label: 'Local 8009', + value: 'ws://localhost:8009/ws', + ), + ], + DropdownMenuEntry( + value: null, + label: 'Custom...', + ), + ], + onSelected: (value) async { + final connectionStateBloc = + BlocProvider.of(context); + value ??= await _askForServerUriMenu(context); + + if (value == null) { + // TODO: restore previous selection. + return; + } + + connectionStateBloc.add(Connect(value)); + }, + ), + playerBlocBuilder(buildProps: (p) => [p.isPausedForCache], builder: (context, state) { + // TODO: why is the server not sending paused-for-cache events? + if (state.isPausedForCache) { + return const CircularProgressIndicator(); + } else { + return const SizedBox.shrink(); + } + }), + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + throw UnimplementedError(); + }, + ), + if (kDebugMode) ...[ + const SizedBox(width: 50), + ], + ], + ); + } + + static Future _askForServerUriMenu(BuildContext context) async { + final textController = TextEditingController(); + + return await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Enter server URI'), + content: TextField( + decoration: const InputDecoration( + labelText: 'Server URI', + ), + controller: textController, + onSubmitted: (value) { + Navigator.of(context).pop(value); + }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final value = textController.text; + textController.dispose(); + Navigator.of(context).pop(value); + }, + child: const Text('Connect'), + ), + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/player_ui/body.dart b/lib/player_ui/body.dart new file mode 100644 index 0000000..3905204 --- /dev/null +++ b/lib/player_ui/body.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:gergle/api/commands.dart'; +import 'package:gergle/api/events.dart'; +import 'package:gergle/player_ui/main.dart'; +import 'package:gergle/state/connection_state_bloc.dart'; +import 'package:gergle/state/player_state.dart'; +import 'package:gergle/state/player_state_bloc.dart'; + +class PlayerUIBody extends StatelessWidget { + PlayerUIBody({super.key}); + + final TextEditingController _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: playerBlocBuilder( + buildProps: (p) => [p.playlist, p.isPlaying], + builder: (context, playerState) => ReorderableListView.builder( + scrollDirection: Axis.vertical, + itemBuilder: (context, i) => + _buildPlaylistTile(context, playerState, i), + itemCount: playerState.playlist.length, + onReorder: (from, to) { + BlocProvider.of(context).add( + Command.playlistMove(from, to), + ); + + if (from < to) { + to -= 1; + } + final item = playerState.playlist.removeAt(from); + playerState.playlist.insert(to, item); + + BlocProvider.of(context) + .add(PlaylistChange(playerState.playlist, local: true)); + }, + ), + ), + ), + SizedBox.fromSize(size: const Size.fromHeight(20)), + _buildInputBar(context), + ], + ); + } + + ListTile _buildPlaylistTile( + BuildContext context, + PlayerState playerState, + int i, + ) { + final item = playerState.playlist[i]; + return ListTile( + key: ValueKey(item.id), + title: Text(item.title ?? item.filename), + subtitle: Text(item.filename), + selected: item.current, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${i + 1}.", + style: Theme.of(context).textTheme.titleLarge, + ), + IconButton( + icon: playerState.isPlaying && item.current + ? const Icon(Icons.pause) + : const Icon(Icons.play_arrow), + onPressed: () { + BlocProvider.of(context) + .add(Command.playlistGoto(i)); + }, + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.copy), + onPressed: () => + Clipboard.setData(ClipboardData(text: item.filename)), + ), + IconButton( + icon: const Icon(Icons.delete_forever), + color: Colors.redAccent, + onPressed: () { + BlocProvider.of(context) + .add(Command.playlistRemove(i)); + }, + ), + ], + ), + ); + } + + Widget _buildInputBar(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: InputDecoration( + labelText: 'Add to playlist', + filled: true, + fillColor: const Color.fromARGB(10, 0, 0, 0), + border: OutlineInputBorder( + borderSide: + BorderSide(color: Theme.of(context).primaryColor, width: 2), + borderRadius: BorderRadius.circular(10), + ), + ), + onSubmitted: (value) { + BlocProvider.of(context) + .add(Command.load(value)); + _textController.clear(); + }, + ), + ), + SizedBox.fromSize(size: const Size(10, 0)), + IconButton( + icon: const Icon(Icons.send), + onPressed: () { + BlocProvider.of(context) + .add(Command.load(_textController.text)); + _textController.clear(); + }, + ), + IconButton( + icon: const Icon(Icons.playlist_add), + onPressed: () async { + final blocProvider = BlocProvider.of(context); + final links = await _showAddManyLinksDialog(context); + + if (links == null) { + return; + } + + for (final link in links.split('\n')) { + blocProvider.add(Command.load(link)); + } + }, + ), + ], + ); + } + + Future _showAddManyLinksDialog(BuildContext context) async { + final textController = TextEditingController(); + + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add many links'), + content: TextField( + controller: textController, + decoration: const InputDecoration( + labelText: 'Links', + hintText: 'One link per line', + ), + maxLines: 10, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(textController.text); + }, + child: const Text('Add'), + ), + ], + ), + ); + } +} diff --git a/lib/player_ui/bottom_bar.dart b/lib/player_ui/bottom_bar.dart new file mode 100644 index 0000000..f280113 --- /dev/null +++ b/lib/player_ui/bottom_bar.dart @@ -0,0 +1,181 @@ +import 'dart:math' show max; +import 'dart:developer' show log; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:gergle/api/commands.dart'; +import 'package:gergle/state/connection_state_bloc.dart'; +import 'package:gergle/player_ui/main.dart'; + +class PlayerUIBottomBar extends StatelessWidget { + const PlayerUIBottomBar({super.key}); + + static String formatTime(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + + if (hours > 0) { + return '$hours:$minutes:$seconds'; + } else { + return '$minutes:$seconds'; + } + } + + @override + Widget build(BuildContext context) { + return BottomAppBar( + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: () { + BlocProvider.of(context) + .add(Command.playlistPrevious()); + }, + ), + playerBlocBuilder( + buildProps: (p) => [p.isPlaying], + builder: (context, playerState) { + return IconButton( + icon: (playerState.isPlaying) + ? const Icon(Icons.pause) + : const Icon(Icons.play_arrow), + onPressed: () { + BlocProvider.of(context) + .add(Command.togglePlayback()); + }, + ); + }, + ), + IconButton( + icon: const Icon(Icons.skip_next), + onPressed: () { + BlocProvider.of(context) + .add(Command.playlistNext()); + }, + ), + Expanded( + child: playerBlocBuilder( + buildProps: (p) => [ + p.cachedTimestamp, + p.duration, + p.currentPercentPosition, + p.volume, + ], + builder: (context, playerState) { + // NOTE: slider throws if the value is over 100, so 99.999 is used to avoid + // hitting the limit with floating point errors. + double cachedPercent = playerState.cachedTimestamp != null + ? ((playerState.cachedTimestamp! / + max(playerState.duration.inMilliseconds, + 0.00000000000001)) * + 1000 * + 99.999) + : 0.0; + if (cachedPercent > 100) { + cachedPercent = 0; + } + return Flex( + direction: Axis.horizontal, + children: [ + Text( + formatTime( + Duration( + milliseconds: + playerState.currentPercentPosition != null + ? (playerState.currentPercentPosition! * + playerState.duration.inMilliseconds * + 0.01) + .round() + : 0, + ), + ), + ), + // Text(((playerState.currentPercentPosition ?? 0.0) * + // 0.01 * + // playerState.duration) + // .toString()), + Expanded( + flex: 5, + child: Slider( + value: playerState.currentPercentPosition ?? 0, + max: 100.0, + secondaryTrackValue: cachedPercent, + onChanged: (value) { + log('Setting time to $value'); + BlocProvider.of(context) + .add(Command.time(value)); + }, + ), + ), + Text(formatTime(playerState.duration)), + // TODO: set minimum width for this slider + Expanded( + flex: 1, + child: Slider( + value: playerState.volume, + max: 130.0, + secondaryTrackValue: 100.0, + onChanged: (value) { + BlocProvider.of(context) + .add(Command.volume(value)); + }, + ), + ), + Text('${playerState.volume.round()}%'), + ], + ); + }, + ), + ), + playerBlocBuilder( + buildProps: (p) => [p.subtitleTracks], + builder: (context, playerState) => PopupMenuButton( + icon: const Icon(Icons.subtitles), + enabled: playerState.subtitleTracks.isNotEmpty, + itemBuilder: (context) { + return playerState.subtitleTracks + .map((track) => PopupMenuItem( + value: track.id, + child: Text(track.title), + )) + .toList(); + }, + onSelected: (value) { + // TODO: add command for changing subtitle track + throw UnimplementedError(); + }, + ), + ), + playerBlocBuilder( + buildProps: (p) => [p.isLooping], + builder: (context, playerState) => IconButton( + icon: const Icon(Icons.repeat), + isSelected: playerState.isLooping, + onPressed: () { + BlocProvider.of(context) + .add(Command.setLooping(!playerState.isLooping)); + }, + ), + ), + IconButton( + icon: const Icon(Icons.shuffle), + onPressed: () { + BlocProvider.of(context) + .add(Command.shuffle()); + }, + ), + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () { + BlocProvider.of(context) + .add(Command.playlistClear()); + }, + ), + ], + ), + ); + } +} diff --git a/lib/player_ui/main.dart b/lib/player_ui/main.dart new file mode 100644 index 0000000..27fd42d --- /dev/null +++ b/lib/player_ui/main.dart @@ -0,0 +1,145 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:gergle/api/commands.dart'; +import 'package:gergle/player_ui/app_bar.dart'; +import 'package:gergle/player_ui/body.dart'; +import 'package:gergle/player_ui/bottom_bar.dart'; +import 'package:gergle/state/connection_state_bloc.dart'; +import 'package:gergle/state/player_state.dart'; +import 'package:gergle/state/player_state_bloc.dart'; + +Widget playerBlocBuilder({ + required List Function(PlayerState) buildProps, + required Widget Function(BuildContext, PlayerState) builder, +}) { + return BlocBuilder( + buildWhen: (previous, current) => + (previous == null) || + (current != null && (buildProps(previous) != buildProps(current))), + builder: (context, playerState) { + if (playerState == null) { + return const Placeholder(); + } + return builder(context, playerState); + }, + ); +} + +class PlayerUi extends StatelessWidget { + const PlayerUi({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is Disconnected) { + return Scaffold( + appBar: PlayerUIAppBar.appbar(context), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Disconnected'), + // TODO: add more here + ], + ), + ), + ); + } + + if (state is Connecting) { + return Scaffold( + appBar: PlayerUIAppBar.appbar(context), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text('Connecting...'), + ], + ), + ), + ); + } + + if (state is ConnectionError) { + final pictureList = [ + 'assets/images/cry1.gif', + 'assets/images/cry2.gif', + ]; + final pictureUri = pictureList[Random().nextInt(pictureList.length)]; + return Scaffold( + appBar: PlayerUIAppBar.appbar(context), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Connection error: ${state.message}'), + const SizedBox(height: 20), + Image.asset( + pictureUri, + scale: 0.7, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => + BlocProvider.of(context) + .add(Connect(state.uri)), + child: const Text('Reconnect'), + ), + ], + ), + ), + ); + } + + return Scaffold( + appBar: PlayerUIAppBar.appbar(context), + body: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Flex(direction: Axis.horizontal, children: [ + const Expanded(flex: 1, child: SizedBox.expand()), + Expanded( + flex: 3, + child: PlayerUIBody(), + ), + const Expanded(flex: 1, child: SizedBox.expand()), + ]), + ), + Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + flex: 1, + child: Image.asset( + 'assets/images/dance2.gif', + alignment: Alignment.bottomCenter, + scale: 1.5, + ), + ), + const Expanded(flex: 3, child: SizedBox.expand()), + Expanded( + flex: 1, + child: Image.asset( + 'assets/images/dance1.webp', + scale: 0.5, + alignment: Alignment.bottomCenter, + ), + ) + ], + ), + ], + ), + bottomNavigationBar: const PlayerUIBottomBar(), + ); + }, + ); + } +} diff --git a/lib/state/connection_state_bloc.dart b/lib/state/connection_state_bloc.dart index 5b46d98..db5848f 100644 --- a/lib/state/connection_state_bloc.dart +++ b/lib/state/connection_state_bloc.dart @@ -1,40 +1,83 @@ import 'dart:convert'; import 'dart:developer' show log; +import 'dart:io'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../api/commands.dart'; -import '../api/events.dart'; -import 'player_state_bloc.dart'; +import 'package:gergle/api/commands.dart'; +import 'package:gergle/api/events.dart'; +import 'package:gergle/state/player_state_bloc.dart'; -class ConnectionStateBloc extends Bloc { +@immutable +sealed class PlayerConnectionState {} + +@immutable +class Disconnected extends PlayerConnectionState {} + +@immutable +class Connecting extends PlayerConnectionState { + final String uri; + + Connecting(this.uri); +} + +@immutable +class Connected extends PlayerConnectionState { + final String uri; + final WebSocketChannel channel; + + Connected(this.uri, this.channel); +} + +@immutable +class ConnectionError extends PlayerConnectionState { + final String message; + final String uri; + + ConnectionError(this.message, this.uri); +} + +class ConnectionStateBloc extends Bloc { final PlayerStateBloc playerStateBloc; - String? _uri; - WebSocketChannel? _channel; - - ConnectionStateBloc(this.playerStateBloc) : super(null) { - - on((event, emit) { - if (_channel != null && _uri == event.uri) { - log('Already connected to ${event.uri}'); - return; - } else if (_channel != null) { - // Clear connection, and reconnect - state?.sink.close(); - playerStateBloc.add(const ClearPlayerState()); - emit(null); + ConnectionStateBloc(this.playerStateBloc) : super(Disconnected()) { + on((event, emit) async { + if (state is Connected) { + if ((state as Connected).uri == event.uri) { + log('Already connected to ${event.uri}'); + return; + } else { + // Clear connection, and reconnect + (state as Connected).channel.sink.close(); + playerStateBloc.add(const ClearPlayerState()); + } } - _uri = event.uri; + emit(Connecting(event.uri)); - _channel = WebSocketChannel.connect( + final channel = WebSocketChannel.connect( Uri.parse(event.uri), ); - _channel!.stream.listen( + try { + await channel.ready; + } on WebSocketChannelException catch (e) { + late final String message; + if (e.inner is WebSocketException) { + message = (e.inner as WebSocketException).message; + } else { + message = e.message ?? e.toString(); + } + + log('Error connecting to ${event.uri}: $message'); + emit(ConnectionError(message, event.uri)); + return; + } + + channel.stream.listen( (event) { final jsonData = jsonDecode(event as String); if (jsonData is Map) { @@ -60,33 +103,37 @@ class ConnectionStateBloc extends Bloc { } } }, - onError: (error) { + onError: (error, stackTrace) { log('Error: $error'); + log('Stack trace: $stackTrace'); }, onDone: () { add(Disconnect()); log('Connection closed, reconnecting...'); - add(Connect(_uri!)); + add(Connect(event.uri)); }, ); - emit(_channel); + emit(Connected(event.uri, channel)); }); on((event, emit) { - _uri = null; - state?.sink.close(0, 'Disconnecting'); + if (state is! Connected) { + log('Cannot disconnect when not connected'); + return; + } + (state as Connected).channel.sink.close(); playerStateBloc.add(const ClearPlayerState()); - emit(null); + emit(Disconnected()); }); on((event, emit) { - if (_channel == null) { + if (state is! Connected) { log('Cannot send command when not connected'); return; } - _channel!.sink.add(event.toJsonString()); + (state as Connected).channel.sink.add(event.toJsonString()); }); } } diff --git a/lib/state/player_state_bloc.dart b/lib/state/player_state_bloc.dart index 0ddf827..d281ce8 100644 --- a/lib/state/player_state_bloc.dart +++ b/lib/state/player_state_bloc.dart @@ -2,8 +2,8 @@ import 'dart:developer' show log; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'player_state.dart'; -import '../api/events.dart'; +import 'package:gergle/api/events.dart'; +import 'package:gergle/state/player_state.dart'; class PlayerStateBloc extends Bloc { PlayerStateBloc() : super(null) { diff --git a/pubspec.yaml b/pubspec.yaml index ab03a83..a363ce3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,39 +1,14 @@ name: gergle -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +description: "A dank music player" +publish_to: 'none' version: 1.0.0+1 environment: sdk: ^3.5.4 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 web_socket_channel: ^3.0.1 flutter_bloc: ^8.1.6 @@ -41,52 +16,10 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^4.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + assets: + - assets/images/ \ No newline at end of file