import 'dart:io'; import 'dart:math'; import 'package:didvan/config/design_config.dart'; import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/app_icons.dart'; import 'package:didvan/constants/assets.dart'; import 'package:didvan/pages/home/direct/direct_state.dart'; import 'package:didvan/services/media/media.dart'; import 'package:didvan/services/storage/storage.dart'; import 'package:didvan/utils/date_time.dart'; import 'package:didvan/widgets/didvan/icon_button.dart'; import 'package:didvan/widgets/didvan/text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:just_waveform/just_waveform.dart'; import 'package:provider/provider.dart'; class AudioVisualizer extends StatefulWidget { final File? audioFile; final Waveform? waveform; final String? audioUrl; final int? duration; final Color? backgroundColor; const AudioVisualizer({ Key? key, this.audioFile, this.waveform, this.audioUrl, this.duration, this.backgroundColor, }) : super(key: key); @override State createState() => _AudioVisualizerState(); } class _AudioVisualizerState extends State { Stream? waveDataStream; @override void initState() { if (!kIsWeb && widget.audioFile != null) { waveDataStream = JustWaveform.extract( audioInFile: widget.audioFile!, waveOutFile: File(StorageService.appTempsDir + '/rec-wave.wave'), zoom: const WaveformZoom.pixelsPerSecond(100), ); } super.initState(); } bool get _nowPlaying => MediaService.lastAudioPath == widget.audioFile || MediaService.lastAudioPath == widget.audioUrl; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: widget.backgroundColor ?? (DesignConfig.isDark ? Theme.of(context).colorScheme.black : Theme.of(context).colorScheme.background), borderRadius: DesignConfig.mediumBorderRadius, ), child: Row( children: [ const SizedBox(width: 12), StreamBuilder( stream: _nowPlaying ? MediaService.audioPlayer.positionStream : null, builder: (context, snapshot) { String text = ''; if (MediaService.audioPlayer.duration == null) { Future.delayed(Duration.zero, () { if (mounted) { setState(() {}); } }); } if (snapshot.data == null || snapshot.data == Duration.zero) { text = DateTimeUtils.normalizeTimeDuration( MediaService.audioPlayer.duration ?? widget.waveform?.duration ?? Duration.zero); } else { text = DateTimeUtils.normalizeTimeDuration(snapshot.data!); } return DidvanText( text, color: Theme.of(context).colorScheme.focusedBorder, isEnglishFont: true, ); }, ), const SizedBox(width: 12), Expanded( child: Builder( builder: (context) { if (kIsWeb) { return SvgPicture.asset(Assets.record); } if (widget.audioFile != null) { return StreamBuilder( stream: waveDataStream, builder: (context, snapshot) { if (snapshot.data == null || snapshot.data!.waveform == null) { return const SizedBox(); } final waveform = snapshot.data!.waveform!; context.read().waveform = waveform; return _waveWidget(waveform); }, ); } if (widget.waveform == null && waveDataStream == null) { return SvgPicture.asset(Assets.record); } return _waveWidget(widget.waveform!); }, ), ), StreamBuilder( stream: _nowPlaying ? MediaService.audioPlayer.playingStream : null, builder: (context, snapshot) { return DidvanIconButton( icon: snapshot.data == true ? DidvanIcons.pause_circle_solid : DidvanIcons.play_circle_solid, color: Theme.of(context).colorScheme.focusedBorder, onPressed: () { MediaService.handleAudioPlayback( audioSource: widget.audioFile ?? widget.audioUrl, isNetworkAudio: widget.audioFile == null, ); setState(() {}); }, ); }, ), ], ), ); } Widget _waveWidget(Waveform waveform) => IgnorePointer( ignoring: !_nowPlaying, child: GestureDetector( onHorizontalDragUpdate: _changePosition, onTapDown: _changePosition, child: SizedBox( height: double.infinity, width: double.infinity, child: _AudioWaveformWidget( waveform: waveform, start: Duration.zero, scale: 2, strokeWidth: 3, nowPlaying: _nowPlaying, duration: waveform.duration, waveColor: Theme.of(context).colorScheme.focusedBorder, ), ), ), ); void _changePosition(details) { if (MediaService.audioPlayer.audioSource == null) return; double posper = details.localPosition.dx / (MediaQuery.of(context).size.width - 200); if (posper >= 1 || posper < 0) return; final position = MediaService.audioPlayer.duration!.inMilliseconds; MediaService.audioPlayer.seek( Duration(milliseconds: (posper * position).toInt()), ); } } class _AudioWaveformWidget extends StatelessWidget { final Color waveColor; final double scale; final double strokeWidth; final double pixelsPerStep; final Waveform waveform; final Duration start; final bool nowPlaying; final Duration duration; const _AudioWaveformWidget({ Key? key, required this.waveform, required this.start, required this.duration, required this.nowPlaying, this.waveColor = Colors.blue, this.scale = 1.0, this.strokeWidth = 5.0, this.pixelsPerStep = 8.0, }) : super(key: key); @override Widget build(BuildContext context) { return ClipRect( child: StreamBuilder( stream: nowPlaying ? MediaService.audioPlayer.positionStream : null, builder: (context, snapshot) { double progress = 0; if (snapshot.data == null || MediaService.audioPlayer.duration == null) { progress = 0; } else { progress = snapshot.data!.inMilliseconds / MediaService.audioPlayer.duration!.inMilliseconds * 100; } if (progress >= 100) { progress = 0; MediaService.audioPlayer.stop(); MediaService.audioPlayer.seek(Duration.zero); } return CustomPaint( painter: _AudioWaveformPainter( waveColor: waveColor, waveform: waveform, start: start, duration: duration, scale: scale, strokeWidth: strokeWidth, pixelsPerStep: pixelsPerStep, progressPercentage: progress, progressColor: Theme.of(context).colorScheme.focusedBorder, color: Theme.of(context).colorScheme.border, ), ); }), ); } } class _AudioWaveformPainter extends CustomPainter { final double scale; final double strokeWidth; final double pixelsPerStep; final Waveform waveform; final Duration start; final Duration duration; final double progressPercentage; final Color progressColor; final Color color; _AudioWaveformPainter({ required this.waveform, required this.start, required this.duration, required this.progressPercentage, required this.color, required this.progressColor, Color waveColor = Colors.blue, this.scale = 1.0, this.strokeWidth = 5.0, this.pixelsPerStep = 8.0, }); @override void paint(Canvas canvas, Size size) { if (duration == Duration.zero) return; double width = size.width; double height = size.height; final waveformPixelsPerWindow = waveform.positionToPixel(duration).toInt(); final waveformPixelsPerDevicePixel = waveformPixelsPerWindow / width; final waveformPixelsPerStep = waveformPixelsPerDevicePixel * pixelsPerStep; final sampleOffset = waveform.positionToPixel(start); final sampleStart = -sampleOffset % waveformPixelsPerStep; final totalLength = waveformPixelsPerWindow; final wavePaintB = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = progressColor; final wavePaintA = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = color; for (var i = sampleStart.toDouble(); i <= waveformPixelsPerWindow + 1.0; i += waveformPixelsPerStep) { final sampleIdx = (sampleOffset + i).toInt(); final x = i / waveformPixelsPerDevicePixel; final minY = normalise(waveform.getPixelMin(sampleIdx), height); final maxY = normalise(waveform.getPixelMax(sampleIdx), height); canvas.drawLine( Offset(x + strokeWidth / 2, max(strokeWidth * 0.75, minY)), Offset(x + strokeWidth / 2, min(height - strokeWidth * 0.75, maxY)), i / totalLength < progressPercentage / 100 ? wavePaintB : wavePaintA, ); } } @override bool shouldRepaint(covariant _AudioWaveformPainter oldDelegate) { return oldDelegate.progressPercentage != progressPercentage; } double normalise(int s, double height) { final y = 32768 + (scale * s).clamp(-32768.0, 32767.0).toDouble(); return height - 1 - y * height / 65536; } }