mymuseum-visitapp/lib/Screens/Sections/Article/audio_player_tab.dart
Thomas Fransolet d39949a6bd Lunettes Meta, lecteur audio en onglet, et badge de version dans les Parametres
Ce commit boucle le travail en cours sur la branche (assistant lunettes Meta,
passage du lecteur audio flottant a un onglet, ajustements scanner / liste de
configurations / telechargement) et y ajoute le badge de version.

Le badge, en bas de la feuille Parametres, affiche « flavor . version . commit ».
Un APK pose sur une tablette du terrain n'etait rattachable a aucun commit
precis : la version du pubspec ne bougeait pas d'un build a l'autre et rien
n'indiquait le flavor reellement installe. kGitSha suit le meme schema que
kApiBaseUrl, injecte par --dart-define, et kFlavor expose le flavor deja calcule.

package_info_plus etait deja une dependance transitive ; il devient direct,
puisqu'il est desormais importe.

/!\ Un --dart-define modifie n'est PAS pris en compte sans `flutter clean` sur
ce projet : verifie a la sentinelle, le SHA restait absent de libapp.so tant que
le cache Dart n'etait pas vide. Un build de release destine au terrain doit donc
toujours passer par un clean, sinon le badge affiche le SHA du build precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:43:53 +02:00

406 lines
13 KiB
Dart

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<AudioPlayerTab> createState() => _AudioPlayerTabState();
}
class _AudioPlayerTabState extends State<AudioPlayerTab>
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<double> _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<Uint8List> fileToUint8List(File file) async {
List<int> 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<AppContext>(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<int> bytes;
LoadedSource(this.bytes);
@override
Future<StreamAudioResponse> 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',
);
}
}