import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/constants.dart'; import 'package:provider/provider.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_cache/just_audio_cache.dart'; class AudioPlayerTab extends StatefulWidget { const AudioPlayerTab({Key? key, required this.file, required this.resourceURl, required this.isAuto}) : super(key: key); final File? file; final String resourceURl; final bool isAuto; @override State createState() => _AudioPlayerTabState(); } class _AudioPlayerTabState extends State with SingleTickerProviderStateMixin { static const double _collapsedWidth = 56; /// Hauteur unique : tout le lecteur tient sur une ligne, la pastille ne fait /// que s'élargir. Elle ne bouge donc pas verticalement en s'ouvrant. static const double _height = 60; /// Bornée à la largeur de l'écran : 320 en dur occupait presque toute la /// largeur d'un téléphone (~394 en logique), la pastille ne se lisait plus /// comme un panneau posé sur la page. double get _expandedWidth => screenSize.width * 0.78 < 320 ? screenSize.width * 0.78 : 320; AudioPlayer player = AudioPlayer(); Uint8List? audiobytes = null; bool isplaying = false; bool audioplayed = false; int currentpos = 0; int maxduration = 100; Duration? durationAudio; String currentpostlabel = "00:00"; bool _isExpanded = false; bool _showContent = false; late AnimationController _controller; /// Progression 0→1, pas des pixels : la largeur dépliée dépend de l'écran, qui /// n'est connu qu'à `didChangeDependencies`, après la création de l'animation. late Animation _expansion; late Size screenSize; @override void didChangeDependencies() { super.didChangeDependencies(); screenSize = MediaQuery.of(context).size; } @override void initState() { _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300)); _expansion = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); //print("IN INITSTATE AUDDDIOOOO"); Future.delayed(Duration.zero, () async { if(widget.file != null) { audiobytes = await fileToUint8List(widget.file!); } player.durationStream.listen((Duration? d) { //get the duration of audio if(d != null) { maxduration = d.inSeconds; durationAudio = d; } }); //player.bufferedPositionStream player.positionStream.listen((event) { if(durationAudio != null) { currentpos = event.inMilliseconds; //get the current position of playing audio //generating the duration label int shours = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inHours; int sminutes = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inMinutes; int sseconds = Duration(milliseconds:durationAudio!.inMilliseconds - currentpos).inSeconds; int rminutes = sminutes - (shours * 60); int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60); String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString(); String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString(); currentpostlabel = "$minutesToShow:$secondsToShow"; } if(mounted && player.duration != null) { setState(() { //refresh the UI if(currentpos > player.duration!.inMilliseconds) { print("RESET ALL"); player.stop(); player.seek(const Duration(seconds: 0)); isplaying = false; audioplayed = false; currentpostlabel = "00:00"; } }); } }); /*player.onPositionChanged.listen((Duration p){ currentpos = p.inMilliseconds; //get the current position of playing audio //generating the duration label int shours = Duration(milliseconds:currentpos).inHours; int sminutes = Duration(milliseconds:currentpos).inMinutes; int sseconds = Duration(milliseconds:currentpos).inSeconds; int rminutes = sminutes - (shours * 60); int rseconds = sseconds - (sminutes * 60 + shours * 60 * 60); String minutesToShow = rminutes < 10 ? '0$rminutes': rminutes.toString(); String secondsToShow = rseconds < 10 ? '0$rseconds': rseconds.toString(); currentpostlabel = "$minutesToShow:$secondsToShow"; setState(() { //refresh the UI }); });*/ if(audiobytes != null) { print("GOT AUDIOBYYYTES - LOCALLY SOSO"); await player.setAudioSource(LoadedSource(audiobytes!)); } else { print("GET SOUND BY URL"); await player.dynamicSet(url: widget.resourceURl); } if(widget.isAuto) { //player.play(BytesSource(audiobytes)); // player.play(); setState(() { isplaying = true; audioplayed = true; }); } }); super.initState(); } @override void dispose() async { _controller.dispose(); Future.microtask(() async { await player.stop(); await player.dispose(); }); super.dispose(); } Future fileToUint8List(File file) async { List bytes = await file.readAsBytes(); return Uint8List.fromList(bytes); } void _togglePlay() { if (isplaying) { player.pause(); setState(() => isplaying = false); } else { player.play(); setState(() { isplaying = true; audioplayed = true; }); } } void _toggleExpansion() { setState(() { if (_isExpanded) { _showContent = false; _isExpanded = false; } else { _isExpanded = true; Future.delayed(const Duration(milliseconds: 300), () { if (_isExpanded && mounted) setState(() => _showContent = true); }); } _isExpanded ? _controller.forward() : _controller.reverse(); }); } double get _progress => maxduration > 0 ? (currentpos / (maxduration * 1000)).clamp(0.0, 1.0) : 0.0; @override Widget build(BuildContext context) { final appContext = Provider.of(context); VisitAppContext visitAppContext = appContext.getContext(); final primaryColor = visitAppContext.configuration?.primaryColor != null ? Color(int.parse( visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)) : kMainColor1; final double rounded = visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0; return AnimatedBuilder( animation: _expansion, builder: (context, child) { final t = _expansion.value; final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t; final radius = BorderRadius.only( topLeft: Radius.circular(rounded), bottomLeft: Radius.circular(rounded), ); return Positioned( right: 0, top: screenSize.height / 2 - _height / 2, child: CustomPaint( // La progression est le contour lui-même : le tracé suit le RRect, donc // il reste juste pendant que la pastille s'étire vers la gauche. foregroundPainter: _ProgressBorderPainter( progress: _progress, radius: radius, color: _isExpanded ? primaryColor : Colors.white, ), child: Container( width: width, height: _height, decoration: BoxDecoration( color: _isExpanded ? kBackgroundColor : primaryColor.withValues(alpha: 0.85), borderRadius: radius, // Posée sur une photo, la pastille a besoin de se détacher : // sans ombre et avec un fond translucide, le lecteur déplié // semblait flotter au milieu de l'image. boxShadow: const [ BoxShadow(color: Colors.black26, blurRadius: 12, offset: Offset(-2, 4)), ], ), child: _showContent ? _expandedContent(primaryColor) : IconButton( icon: Icon(isplaying ? Icons.pause : Icons.play_arrow, color: Colors.white), onPressed: _toggleExpansion, ), ), ), ); }, ); } /// Tout sur une seule ligne, croix comprise : avec la fermeture sur sa propre /// rangée, la pastille devait doubler de hauteur pour s'ouvrir. Ici elle ne fait /// que s'élargir. Widget _expandedContent(Color primaryColor) { return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 4, 0), child: Row( children: [ InkWell( onTap: _togglePlay, customBorder: const CircleBorder(), child: Container( width: 44, height: 44, decoration: BoxDecoration(color: primaryColor, shape: BoxShape.circle), child: Icon(isplaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 26), ), ), Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 3, activeTrackColor: primaryColor, inactiveTrackColor: kMainGrey, thumbColor: primaryColor, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), ), child: Slider( value: _progress, onChanged: maxduration > 0 ? (v) => player .seek(Duration(milliseconds: (v * maxduration * 1000).round())) : null, ), ), ), Text( currentpostlabel, style: const TextStyle( fontSize: 13, fontFeatures: [FontFeature.tabularFigures()], ), ), IconButton( icon: Icon(Icons.close, color: primaryColor, size: 20), onPressed: _toggleExpansion, ), ], ), ); } } /// Ne dessine qu'une fraction du contour, mesurée le long du chemin plutôt qu'en /// angles : un `RRect` n'a pas de centre unique, un arc ne suivrait pas ses coins. class _ProgressBorderPainter extends CustomPainter { const _ProgressBorderPainter({ required this.progress, required this.radius, required this.color, }); final double progress; final BorderRadius radius; final Color color; /// Contour tracé à la main plutôt que par `addRRect` : celui-ci démarre au /// milieu du côté gauche, donc la progression naissait là. On part du coin bas /// droit et on tourne dans le sens horaire — bas, côté arrondi, haut, puis le /// bord collé à l'écran. Path _borderPath(Size size) { final w = size.width; final h = size.height; final tl = radius.topLeft.x; final bl = radius.bottomLeft.x; return Path() ..moveTo(w, h) ..lineTo(bl, h) ..arcToPoint(Offset(0, h - bl), radius: Radius.circular(bl), clockwise: true) ..lineTo(0, tl) ..arcToPoint(Offset(tl, 0), radius: Radius.circular(tl), clockwise: true) ..lineTo(w, 0) ..lineTo(w, h); } @override void paint(Canvas canvas, Size size) { final path = _borderPath(size); // Piste complète d'abord : sans elle, la pastille n'a aucun contour tant que // l'audio n'a pas démarré, et rien ne dit qu'elle en gagnera un. canvas.drawPath( path, Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..color = color.withValues(alpha: 0.25), ); if (progress <= 0) return; final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..strokeCap = StrokeCap.round ..color = color; for (final metric in path.computeMetrics()) { canvas.drawPath(metric.extractPath(0, metric.length * progress), paint); } } @override bool shouldRepaint(_ProgressBorderPainter old) => old.progress != progress || old.color != color || old.radius != radius; } // Feed your own stream of bytes into the player class LoadedSource extends StreamAudioSource { final List bytes; LoadedSource(this.bytes); @override Future request([int? start, int? end]) async { start ??= 0; end ??= bytes.length; return StreamAudioResponse( sourceLength: bytes.length, contentLength: end - start, offset: start, stream: Stream.value(bytes.sublist(start, end)), contentType: 'audio/mpeg', ); } }