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>
406 lines
13 KiB
Dart
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',
|
|
);
|
|
}
|
|
}
|