Multiple changes:

- Split up UI into several files
- Handle connection state better
- Add images
- Granular rebuilding of state dependent widgets
- Fix usage of alert dialogs
- Add some basic theming
- Add dialog for adding multiple links at once
This commit is contained in:
2024-12-18 20:12:14 +01:00
parent 540b504c2e
commit 6b82280c14
15 changed files with 719 additions and 525 deletions

108
lib/player_ui/app_bar.dart Normal file
View File

@@ -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<String?>>[
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<ConnectionStateBloc>(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<String?> _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'),
),
],
);
},
);
}
}

186
lib/player_ui/body.dart Normal file
View File

@@ -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<ConnectionStateBloc>(context).add(
Command.playlistMove(from, to),
);
if (from < to) {
to -= 1;
}
final item = playerState.playlist.removeAt(from);
playerState.playlist.insert(to, item);
BlocProvider.of<PlayerStateBloc>(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<ConnectionStateBloc>(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<ConnectionStateBloc>(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<ConnectionStateBloc>(context)
.add(Command.load(value));
_textController.clear();
},
),
),
SizedBox.fromSize(size: const Size(10, 0)),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.load(_textController.text));
_textController.clear();
},
),
IconButton(
icon: const Icon(Icons.playlist_add),
onPressed: () async {
final blocProvider = BlocProvider.of<ConnectionStateBloc>(context);
final links = await _showAddManyLinksDialog(context);
if (links == null) {
return;
}
for (final link in links.split('\n')) {
blocProvider.add(Command.load(link));
}
},
),
],
);
}
Future<String?> _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'),
),
],
),
);
}
}

View File

@@ -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<ConnectionStateBloc>(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<ConnectionStateBloc>(context)
.add(Command.togglePlayback());
},
);
},
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(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<ConnectionStateBloc>(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<ConnectionStateBloc>(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<ConnectionStateBloc>(context)
.add(Command.setLooping(!playerState.isLooping));
},
),
),
IconButton(
icon: const Icon(Icons.shuffle),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.shuffle());
},
),
IconButton(
icon: const Icon(Icons.delete_forever),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.playlistClear());
},
),
],
),
);
}
}

145
lib/player_ui/main.dart Normal file
View File

@@ -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<dynamic> Function(PlayerState) buildProps,
required Widget Function(BuildContext, PlayerState) builder,
}) {
return BlocBuilder<PlayerStateBloc, PlayerState?>(
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<ConnectionStateBloc, PlayerConnectionState>(
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<ConnectionStateBloc>(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(),
);
},
);
}
}