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)); friends.sort((a, b) => a.username.length.compareTo(b.username.length));
} }
return ListView.builder( return ListView.builder(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
itemCount: friends.length, itemCount: friends.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final friend = friends[index]; final friend = friends[index];

View file

@ -19,7 +19,7 @@ class MessageAudioPlayer extends StatefulWidget {
State<MessageAudioPlayer> createState() => _MessageAudioPlayerState(); State<MessageAudioPlayer> createState() => _MessageAudioPlayerState();
} }
class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver, AutomaticKeepAliveClientMixin { class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBindingObserver {
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
Future? _audioFileFuture; Future? _audioFileFuture;
double _sliderValue = 0; double _sliderValue = 0;
@ -41,22 +41,32 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
final audioCache = Provider.of<AudioCacheClient>(context); final audioCache = Provider.of<AudioCacheClient>(context);
_audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) _audioFileFuture = audioCache
.then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); .cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content)))
.then((value) => _audioPlayer.setFilePath(value.path))
.whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off));
} }
@override @override
void didUpdateWidget(covariant MessageAudioPlayer oldWidget) { void didUpdateWidget(covariant MessageAudioPlayer oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.message.id == widget.message.id) return;
final audioCache = Provider.of<AudioCacheClient>(context); final audioCache = Provider.of<AudioCacheClient>(context);
_audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) _audioFileFuture = audioCache
.then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); .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 @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_audioPlayer.dispose(); _audioPlayer.dispose().onError((error, stackTrace) {});
super.dispose(); super.dispose();
} }
@ -66,22 +76,19 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(Icons.error_outline, color: Theme Icon(
.of(context) Icons.error_outline,
.colorScheme color: Theme.of(context).colorScheme.error,
.error,), ),
const SizedBox(height: 4,), const SizedBox(
Text(error, textAlign: TextAlign.center, height: 4,
),
Text(
error,
textAlign: TextAlign.center,
softWrap: true, softWrap: true,
maxLines: 3, maxLines: 3,
style: Theme style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error),
.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme
.of(context)
.colorScheme
.error),
), ),
], ],
), ),
@ -90,147 +97,140 @@ class _MessageAudioPlayerState extends State<MessageAudioPlayer> with WidgetsBin
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform.");
} }
return FutureBuilder(
future: _audioFileFuture, return IntrinsicWidth(
builder: (context, snapshot) { child: StreamBuilder<PlayerState>(
if (snapshot.hasError) { stream: _audioPlayer.playerStateStream,
return SizedBox( builder: (context, snapshot) {
width: 300, if (snapshot.hasError) {
child: Row( FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
children: [ return _createErrorWidget("Failed to load audio-message.");
const Icon(Icons.volume_off), }
const SizedBox(width: 8,), final playerState = snapshot.data;
Expanded( return Column(
child: Text( crossAxisAlignment: CrossAxisAlignment.center,
"Failed to load voice message: ${snapshot.error}", mainAxisAlignment: MainAxisAlignment.center,
maxLines: 4, children: [
overflow: TextOverflow.ellipsis, Row(
softWrap: true, mainAxisSize: MainAxisSize.max,
), mainAxisAlignment: MainAxisAlignment.spaceBetween,
), children: [
], FutureBuilder(
), future: _audioFileFuture,
); builder: (context, fileSnapshot) {
} if (fileSnapshot.hasError) {
return IntrinsicWidth( return const IconButton(
child: StreamBuilder<PlayerState>( icon: Icon(Icons.warning),
stream: _audioPlayer.playerStateStream, onPressed: null,
builder: (context, snapshot) { );
if (snapshot.hasData) { }
final playerState = snapshot.data as PlayerState; return IconButton(
return Column( onPressed: fileSnapshot.hasData &&
crossAxisAlignment: CrossAxisAlignment.center, snapshot.hasData &&
mainAxisAlignment: MainAxisAlignment.center, playerState != null &&
children: [ playerState.processingState != ProcessingState.loading
Row( ? () {
mainAxisSize: MainAxisSize.max, switch (playerState.processingState) {
mainAxisAlignment: MainAxisAlignment.spaceBetween, case ProcessingState.idle:
children: [ case ProcessingState.loading:
snapshot.hasData ? IconButton( case ProcessingState.buffering:
onPressed: () { break;
switch (playerState.processingState) { case ProcessingState.ready:
case ProcessingState.idle: if (playerState.playing) {
case ProcessingState.loading: _audioPlayer.pause();
case ProcessingState.buffering: } else {
break; _audioPlayer.play();
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(),
));
},
),
);
} }
); break;
case ProcessingState.completed:
_audioPlayer.seek(Duration.zero);
_audioPlayer.play();
break;
}
} }
) : null,
], color: widget.foregroundColor,
), icon: Icon(
Row( ((_audioPlayer.duration ?? const Duration(days: 9999)) - _audioPlayer.position)
mainAxisSize: MainAxisSize.max, .inMilliseconds <
mainAxisAlignment: MainAxisAlignment.spaceEvenly, 10
children: [ ? Icons.replay
const SizedBox(width: 4,), : ((playerState?.playing ?? false) ? Icons.pause : Icons.play_arrow),
StreamBuilder( ),
stream: _audioPlayer.positionStream, );
builder: (context, snapshot) { },
return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? ),
"??"}", StreamBuilder(
style: Theme stream: _audioPlayer.positionStream,
.of(context) builder: (context, snapshot) {
.textTheme _sliderValue = _audioPlayer.duration == null
.bodySmall ? 0
?.copyWith(color: widget.foregroundColor?.withAlpha(150)), : (_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)); Row(
return _createErrorWidget("Failed to load audio-message."); mainAxisSize: MainAxisSize.max,
} else { mainAxisAlignment: MainAxisAlignment.spaceEvenly,
return const Center(child: CircularProgressIndicator(),); 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; _showSessionListScrollChevron = true;
}); });
} }
if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0 if (_sessionListScrollController.position.atEdge &&
&& _showSessionListScrollChevron) { _sessionListScrollController.position.pixels > 0 &&
_showSessionListScrollChevron) {
setState(() { setState(() {
_showSessionListScrollChevron = false; _showSessionListScrollChevron = false;
}); });
@ -54,185 +55,191 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sessions = widget.friend.userStatus.activeSessions; final sessions = widget.friend.userStatus.activeSessions;
final appBarColor = Theme final appBarColor = Theme.of(context).colorScheme.surfaceVariant;
.of(context) return Consumer<MessagingClient>(builder: (context, mClient, _) {
.colorScheme final cache = mClient.getUserMessageCache(widget.friend.id);
.surfaceVariant; return Scaffold(
return Consumer<MessagingClient>( appBar: AppBar(
builder: (context, mClient, _) { title: Row(
final cache = mClient.getUserMessageCache(widget.friend.id); crossAxisAlignment: CrossAxisAlignment.center,
return Scaffold( children: [
appBar: AppBar( FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus),
title: Row( const SizedBox(
crossAxisAlignment: CrossAxisAlignment.center, 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: [ children: [
FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus), Builder(
const SizedBox(width: 8,), builder: (context) {
Text(widget.friend.username), if (cache == null) {
if (widget.friend.isHeadless) Padding( return const Column(
padding: const EdgeInsets.only(left: 12), mainAxisAlignment: MainAxisAlignment.start,
child: Icon(Icons.dns, size: 18, color: Theme children: [LinearProgressIndicator()],
.of(context) );
.colorScheme }
.onSecondaryContainer if (cache.error != null) {
.withAlpha(150), 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( MessageInputBar(
children: [ recipient: widget.friend,
if (sessions.isNotEmpty) AnimatedSwitcher( disabled: cache == null || cache.error != null,
duration: const Duration(milliseconds: 200), onMessageSent: () {
transitionBuilder: (child, animation) => SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child), setState(() {});
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(() {});
},
),
],
), ),
); ],
} ),
); );
});
} }
} }