Refactor audio-message error handling

This commit is contained in:
Nutcake 2023-05-28 18:28:04 +02:00
parent 9ab4774f34
commit aa882a13ae
3 changed files with 340 additions and 333 deletions

View file

@ -252,7 +252,7 @@ class _FriendsListState extends State<FriendsList> {
friends.sort((a, b) => a.username.length.compareTo(b.username.length));
}
return ListView.builder(
physics: const BouncingScrollPhysics(),
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
itemCount: friends.length,
itemBuilder: (context, index) {
final friend = friends[index];

View file

@ -19,7 +19,7 @@ class MessageAudioPlayer extends StatefulWidget {
State<MessageAudioPlayer> createState() => _MessageAudioPlayerState();
}
class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver {
final AudioPlayer _audioPlayer = AudioPlayer();
Future? _audioFileFuture;
double _sliderValue = 0;
@ -41,22 +41,32 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
void didChangeDependencies() {
super.didChangeDependencies();
final audioCache = Provider.of<AudioCacheClient>(context);
_audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off));
_audioFileFuture = audioCache
.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) => _audioPlayer.setFilePath(value.path))
.whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off));
}
@override
void didUpdateWidget(covariant MessageAudioPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.id == widget.message.id) return;
final audioCache = Provider.of<AudioCacheClient>(context);
_audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off));
_audioFileFuture = audioCache
.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) async {
final path = _audioPlayer.setFilePath(value.path);
await _audioPlayer.setLoopMode(LoopMode.off);
await _audioPlayer.pause();
await _audioPlayer.seek(Duration.zero);
return path;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_audioPlayer.dispose();
_audioPlayer.dispose().onError((error, stackTrace) {});
super.dispose();
}
@ -66,22 +76,19 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Theme
.of(context)
.colorScheme
.error,),
const SizedBox(height: 4,),
Text(error, textAlign: TextAlign.center,
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(
height: 4,
),
Text(
error,
textAlign: TextAlign.center,
softWrap: true,
maxLines: 3,
style: Theme
.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme
.of(context)
.colorScheme
.error),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error),
),
],
),
@ -90,147 +97,140 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
@override
Widget build(BuildContext context) {
super.build(context);
if (!Platform.isAndroid) {
return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform.");
}
return FutureBuilder(
future: _audioFileFuture,
builder: (context, snapshot) {
if (snapshot.hasError) {
return SizedBox(
width: 300,
child: Row(
children: [
const Icon(Icons.volume_off),
const SizedBox(width: 8,),
Expanded(
child: Text(
"Failed to load voice message: ${snapshot.error}",
maxLines: 4,
overflow: TextOverflow.ellipsis,
softWrap: true,
),
),
],
),
);
}
return IntrinsicWidth(
child: StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
final playerState = snapshot.data as PlayerState;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
snapshot.hasData ? IconButton(
onPressed: () {
switch (playerState.processingState) {
case ProcessingState.idle:
case ProcessingState.loading:
case ProcessingState.buffering:
break;
case ProcessingState.ready:
if (playerState.playing) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
break;
case ProcessingState.completed:
_audioPlayer.seek(Duration.zero);
_audioPlayer.play();
break;
}
},
color: widget.foregroundColor,
icon: SizedBox.square(
dimension: 24,
child: playerState.processingState == ProcessingState.loading
? const Center(child: CircularProgressIndicator(),)
: Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds <
10 ? Icons.replay
: (playerState.playing ? Icons.pause : Icons.play_arrow)),
),
) : const SizedBox.square(dimension: 24, child: CircularProgressIndicator(),),
StreamBuilder(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
_sliderValue = _audioPlayer.duration == null ? 0 : (_audioPlayer.position.inMilliseconds /
(_audioPlayer.duration!.inMilliseconds)).clamp(0, 1);
return StatefulBuilder( // Not sure if this makes sense here...
builder: (context, setState) {
return SliderTheme(
data: SliderThemeData(
inactiveTrackColor: widget.foregroundColor?.withAlpha(100),
),
child: Slider(
thumbColor: widget.foregroundColor,
value: _sliderValue,
min: 0.0,
max: 1.0,
onChanged: (value) async {
_audioPlayer.pause();
setState(() {
_sliderValue = value;
});
_audioPlayer.seek(Duration(
milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(),
));
},
),
);
return IntrinsicWidth(
child: StreamBuilder<PlayerState>(
stream: _audioPlayer.playerStateStream,
builder: (context, snapshot) {
if (snapshot.hasError) {
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return _createErrorWidget("Failed to load audio-message.");
}
final playerState = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FutureBuilder(
future: _audioFileFuture,
builder: (context, fileSnapshot) {
if (fileSnapshot.hasError) {
return const IconButton(
icon: Icon(Icons.warning),
onPressed: null,
);
}
return IconButton(
onPressed: fileSnapshot.hasData &&
snapshot.hasData &&
playerState != null &&
playerState.processingState != ProcessingState.loading
? () {
switch (playerState.processingState) {
case ProcessingState.idle:
case ProcessingState.loading:
case ProcessingState.buffering:
break;
case ProcessingState.ready:
if (playerState.playing) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
);
break;
case ProcessingState.completed:
_audioPlayer.seek(Duration.zero);
_audioPlayer.play();
break;
}
}
)
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const SizedBox(width: 4,),
StreamBuilder(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ??
"??"}",
style: Theme
.of(context)
.textTheme
.bodySmall
?.copyWith(color: widget.foregroundColor?.withAlpha(150)),
: null,
color: widget.foregroundColor,
icon: Icon(
((_audioPlayer.duration ?? const Duration(days: 9999)) - _audioPlayer.position)
.inMilliseconds <
10
? Icons.replay
: ((playerState?.playing ?? false) ? Icons.pause : Icons.play_arrow),
),
);
},
),
StreamBuilder(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
_sliderValue = _audioPlayer.duration == null
? 0
: (_audioPlayer.position.inMilliseconds / (_audioPlayer.duration!.inMilliseconds))
.clamp(0, 1);
return StatefulBuilder(
// Not sure if this makes sense here...
builder: (context, setState) {
return SliderTheme(
data: SliderThemeData(
inactiveTrackColor: widget.foregroundColor?.withAlpha(100),
),
child: Slider(
thumbColor: widget.foregroundColor,
value: _sliderValue,
min: 0.0,
max: 1.0,
onChanged: (value) async {
_audioPlayer.pause();
setState(() {
_sliderValue = value;
});
_audioPlayer.seek(
Duration(
milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(),
),
);
}
),
const Spacer(),
MessageStateIndicator(message: widget.message, foregroundColor: widget.foregroundColor,),
],
)
],
);
} else if (snapshot.hasError) {
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return _createErrorWidget("Failed to load audio-message.");
} else {
return const Center(child: CircularProgressIndicator(),);
}
}
),
);
}
},
),
);
},
);
},
)
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const SizedBox(
width: 4,
),
StreamBuilder(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
return Text(
"${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? "??"}",
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: widget.foregroundColor?.withAlpha(150)),
);
},
),
const Spacer(),
MessageStateIndicator(
message: widget.message,
foregroundColor: widget.foregroundColor,
),
],
),
],
);
},
),
);
}
@override
// TODO: implement wantKeepAlive
bool get wantKeepAlive => true;
}
}

View file

@ -42,8 +42,9 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
_showSessionListScrollChevron = true;
});
}
if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0
&& _showSessionListScrollChevron) {
if (_sessionListScrollController.position.atEdge &&
_sessionListScrollController.position.pixels > 0 &&
_showSessionListScrollChevron) {
setState(() {
_showSessionListScrollChevron = false;
});
@ -54,185 +55,191 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
@override
Widget build(BuildContext context) {
final sessions = widget.friend.userStatus.activeSessions;
final appBarColor = Theme
.of(context)
.colorScheme
.surfaceVariant;
return Consumer<MessagingClient>(
builder: (context, mClient, _) {
final cache = mClient.getUserMessageCache(widget.friend.id);
return Scaffold(
appBar: AppBar(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
final appBarColor = Theme.of(context).colorScheme.surfaceVariant;
return Consumer<MessagingClient>(builder: (context, mClient, _) {
final cache = mClient.getUserMessageCache(widget.friend.id);
return Scaffold(
appBar: AppBar(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus),
const SizedBox(
width: 8,
),
Text(widget.friend.username),
if (widget.friend.isHeadless)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(
Icons.dns,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer.withAlpha(150),
),
),
],
),
bottom: sessions.isNotEmpty && _sessionListOpen
? null
: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: Colors.black,
),
),
actions: [
if (sessions.isNotEmpty)
AnimatedRotation(
turns: _sessionListOpen ? -1 / 4 : 1 / 4,
duration: const Duration(milliseconds: 200),
child: IconButton(
onPressed: () {
setState(() {
_sessionListOpen = !_sessionListOpen;
});
},
icon: const Icon(Icons.chevron_right),
),
),
const SizedBox(
width: 4,
)
],
scrolledUnderElevation: 0.0,
backgroundColor: appBarColor,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
),
body: Column(
children: [
if (sessions.isNotEmpty)
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) =>
SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child),
child: sessions.isEmpty || !_sessionListOpen
? null
: Container(
constraints: const BoxConstraints(maxHeight: 64),
decoration: BoxDecoration(
color: appBarColor,
border: const Border(
bottom: BorderSide(width: 1, color: Colors.black),
)),
child: Stack(
children: [
ListView.builder(
controller: _sessionListScrollController,
scrollDirection: Axis.horizontal,
itemCount: sessions.length,
itemBuilder: (context, index) => SessionTile(session: sessions[index]),
),
AnimatedOpacity(
opacity: _shevronOpacity,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 200),
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
appBarColor.withOpacity(0),
appBarColor,
appBarColor,
],
),
),
height: double.infinity,
child: const Icon(Icons.chevron_right),
),
),
)
],
),
),
),
Expanded(
child: Stack(
children: [
FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus),
const SizedBox(width: 8,),
Text(widget.friend.username),
if (widget.friend.isHeadless) Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(Icons.dns, size: 18, color: Theme
.of(context)
.colorScheme
.onSecondaryContainer
.withAlpha(150),
),
Builder(
builder: (context) {
if (cache == null) {
return const Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [LinearProgressIndicator()],
);
}
if (cache.error != null) {
return DefaultErrorWidget(
message: cache.error.toString(),
onRetry: () {
setState(() {
mClient.deleteUserMessageCache(widget.friend.id);
});
mClient.loadUserMessageCache(widget.friend.id);
},
);
}
if (cache.messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.message_outlined),
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
"There are no messages here\nWhy not say hello?",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
)
],
),
);
}
return Provider(
create: (BuildContext context) => AudioCacheClient(),
child: ListView.builder(
reverse: true,
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
itemCount: cache.messages.length,
itemBuilder: (context, index) {
final entry = cache.messages[index];
if (index == cache.messages.length - 1) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: MessageBubble(
message: entry,
),
);
}
return MessageBubble(
message: entry,
);
},
),
);
},
),
],
),
bottom: sessions.isNotEmpty && _sessionListOpen ? null : PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: Colors.black,
),
),
actions: [
if (sessions.isNotEmpty) AnimatedRotation(
turns: _sessionListOpen ? -1/4 : 1/4,
duration: const Duration(milliseconds: 200),
child: IconButton(
onPressed: () {
setState(() {
_sessionListOpen = !_sessionListOpen;
});
},
icon: const Icon(Icons.chevron_right),
),
),
const SizedBox(width: 4,)
],
scrolledUnderElevation: 0.0,
backgroundColor: appBarColor,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
),
body: Column(
children: [
if (sessions.isNotEmpty) AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child),
child: sessions.isEmpty || !_sessionListOpen ? null : Container(
constraints: const BoxConstraints(maxHeight: 64),
decoration: BoxDecoration(
color: appBarColor,
border: const Border(bottom: BorderSide(width: 1, color: Colors.black),)
),
child: Stack(
children: [
ListView.builder(
controller: _sessionListScrollController,
scrollDirection: Axis.horizontal,
itemCount: sessions.length,
itemBuilder: (context, index) => SessionTile(session: sessions[index]),
),
AnimatedOpacity(
opacity: _shevronOpacity,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 200),
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
appBarColor.withOpacity(0),
appBarColor,
appBarColor,
],
),
),
height: double.infinity,
child: const Icon(Icons.chevron_right),
),
),
)
],
),
),
),
Expanded(
child: Stack(
children: [
Builder(
builder: (context) {
if (cache == null) {
return const Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
LinearProgressIndicator()
],
);
}
if (cache.error != null) {
return DefaultErrorWidget(
message: cache.error.toString(),
onRetry: () {
setState(() {
mClient.deleteUserMessageCache(widget.friend.id);
});
mClient.loadUserMessageCache(widget.friend.id);
},
);
}
if (cache.messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.message_outlined),
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
"There are no messages here\nWhy not say hello?",
textAlign: TextAlign.center,
style: Theme
.of(context)
.textTheme
.titleMedium,
),
)
],
),
);
}
return Provider(
create: (BuildContext context) => AudioCacheClient(),
child: ListView.builder(
reverse: true,
physics: const BouncingScrollPhysics(),
itemCount: cache.messages.length,
itemBuilder: (context, index) {
final entry = cache.messages[index];
if (index == cache.messages.length - 1) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: MessageBubble(message: entry,),
);
}
return MessageBubble(message: entry,);
},
),
);
},
),
],
),
),
MessageInputBar(
recipient: widget.friend,
disabled: cache == null || cache.error != null,
onMessageSent: () {
setState(() {});
},
),
],
MessageInputBar(
recipient: widget.friend,
disabled: cache == null || cache.error != null,
onMessageSent: () {
setState(() {});
},
),
);
}
);
],
),
);
});
}
}