386 lines
16 KiB
Dart
386 lines
16 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:cross_file/cross_file.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hoshan/core/gen/assets.gen.dart';
|
|
import 'package:hoshan/core/services/api/dio_service.dart';
|
|
import 'package:hoshan/core/services/downloader/downloader_service.dart';
|
|
import 'package:hoshan/ui/theme/colors.dart';
|
|
import 'package:hoshan/ui/theme/text.dart';
|
|
import 'package:hoshan/ui/widgets/components/button/circle_icon_btn.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:percent_indicator/circular_percent_indicator.dart';
|
|
import 'package:string_validator/string_validator.dart';
|
|
|
|
class MusicPlayer extends StatefulWidget {
|
|
final String url;
|
|
const MusicPlayer({super.key, required this.url});
|
|
|
|
@override
|
|
State<MusicPlayer> createState() => _MusicPlayerState();
|
|
}
|
|
|
|
class _MusicPlayerState extends State<MusicPlayer> {
|
|
final player = AudioPlayer();
|
|
dynamic fileSource;
|
|
bool isReady = false;
|
|
Duration? duration;
|
|
|
|
final DownloaderService _downloadFileService = DownloaderService();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
try {
|
|
if (widget.url.isNotEmpty) {
|
|
if (!widget.url.isURL()) {
|
|
XFile? file = XFile(widget.url);
|
|
fileSource = DeviceFileSource(file.path);
|
|
} else {
|
|
var fileName = widget.url.split('/').last;
|
|
if (!fileName.endsWith('.mp3')) {
|
|
fileName = '$fileName.mp3';
|
|
}
|
|
final directory = await getApplicationDocumentsDirectory();
|
|
final file = File('${directory.path}/$fileName');
|
|
if (await directory.exists() && await file.exists()) {
|
|
fileSource = DeviceFileSource(file.path);
|
|
}
|
|
}
|
|
if (fileSource != null) {
|
|
try {
|
|
await player.setSource(fileSource!);
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('Error in setSource: $e');
|
|
}
|
|
}
|
|
duration = await player.getDuration();
|
|
setState(() {
|
|
isReady = true;
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('Error in initState: $e');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
player.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.url.isEmpty) {
|
|
return const Text('Audio not found');
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ValueListenableBuilder(
|
|
valueListenable: _downloadFileService.onStatus,
|
|
builder: (context, status, _) {
|
|
return status == TaskStatus.complete || isReady
|
|
? StreamBuilder<PlayerState>(
|
|
stream: player.onPlayerStateChanged,
|
|
builder: (context, snapshot) {
|
|
return CircleIconBtn(
|
|
icon: (snapshot.hasData ||
|
|
!snapshot.hasError) &&
|
|
snapshot.data == PlayerState.playing
|
|
? Assets.icon.bold.pause
|
|
: Assets.icon.bold.play,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
iconColor: Colors.white,
|
|
iconPadding: const EdgeInsets.all(6),
|
|
size: 32,
|
|
onTap: () async {
|
|
if (snapshot.data == PlayerState.playing) {
|
|
await player.pause();
|
|
} else if (snapshot.data ==
|
|
PlayerState.paused) {
|
|
await player.resume();
|
|
} else {
|
|
if (fileSource != null) {
|
|
await player.play(fileSource!);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
})
|
|
: Container(
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Theme.of(context).colorScheme.primary),
|
|
child: InkWell(
|
|
onTap: () async {
|
|
if (status == null) {
|
|
final path = await _downloadFileService
|
|
.downloadFile(widget.url);
|
|
if (path != null) {
|
|
XFile? file = XFile(path);
|
|
fileSource = DeviceFileSource(file.path);
|
|
}
|
|
if (fileSource != null) {
|
|
try {
|
|
await player.setSource(fileSource!);
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('Error in setSource: $e');
|
|
}
|
|
}
|
|
duration = await player.getDuration();
|
|
setState(() {
|
|
isReady = true;
|
|
});
|
|
}
|
|
} else if (status == TaskStatus.running) {
|
|
_downloadFileService.pauseDownload();
|
|
} else {
|
|
_downloadFileService.resumeDownload();
|
|
}
|
|
},
|
|
child: ValueListenableBuilder(
|
|
valueListenable:
|
|
_downloadFileService.progressPer,
|
|
builder: (context, progress, _) => progress ==
|
|
0 &&
|
|
status != null &&
|
|
status == TaskStatus.running ||
|
|
progress < 0
|
|
? const SizedBox(
|
|
width: 28,
|
|
height: 28,
|
|
child: Padding(
|
|
padding: EdgeInsets.all(2.0),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.download_rounded,
|
|
color: Colors.white,
|
|
size: 16,
|
|
),
|
|
Positioned.fill(
|
|
child:
|
|
CircularProgressIndicator(
|
|
color: Colors.white,
|
|
strokeWidth: 2,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: CircularPercentIndicator(
|
|
radius: 14.0,
|
|
animation: true,
|
|
animationDuration: 100,
|
|
lineWidth: 2.0,
|
|
percent: progress,
|
|
animateFromLastPercent: true,
|
|
center: status != null &&
|
|
status == TaskStatus.running
|
|
? Text(
|
|
(progress * 100)
|
|
.abs()
|
|
.round()
|
|
.toString(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10.0),
|
|
)
|
|
: const Icon(
|
|
Icons.download,
|
|
color: Colors.white,
|
|
size: 14,
|
|
),
|
|
circularStrokeCap:
|
|
CircularStrokeCap.round,
|
|
backgroundColor: AppColors.gray[300],
|
|
progressColor:
|
|
AppColors.green.defaultShade,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
const SizedBox(
|
|
width: 8,
|
|
),
|
|
InkWell(
|
|
onTap: () async {
|
|
final pos = await player.getCurrentPosition();
|
|
if (pos != null) {
|
|
final targetPosition =
|
|
Duration(seconds: pos.inSeconds - 5);
|
|
|
|
await player.seek(targetPosition);
|
|
} else {
|
|
if (kDebugMode) {
|
|
print('Failed to get current position');
|
|
}
|
|
}
|
|
},
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Theme.of(context).colorScheme.primary),
|
|
child: const Icon(
|
|
Icons.replay_5_rounded,
|
|
color: Colors.white,
|
|
size: 22,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(
|
|
width: 8,
|
|
),
|
|
InkWell(
|
|
onTap: () async {
|
|
final pos = await player.getCurrentPosition();
|
|
if (pos != null) {
|
|
final targetPosition =
|
|
Duration(seconds: pos.inSeconds + 5);
|
|
|
|
await player.seek(targetPosition);
|
|
} else {
|
|
if (kDebugMode) {
|
|
print('Failed to get current position');
|
|
}
|
|
}
|
|
},
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Theme.of(context).colorScheme.primary),
|
|
child: const Icon(
|
|
Icons.forward_5_rounded,
|
|
color: Colors.white,
|
|
size: 22,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
if (player.playbackRate == 2) {
|
|
player.setPlaybackRate(1);
|
|
} else {
|
|
player.setPlaybackRate(2);
|
|
}
|
|
});
|
|
},
|
|
child: Text(
|
|
player.playbackRate == 2 ? '2X' : '1X',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: Theme.of(context).colorScheme.primary),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
const SizedBox(
|
|
height: 16,
|
|
),
|
|
if (isReady)
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (duration != null)
|
|
SizedBox(
|
|
child: StreamBuilder<Duration>(
|
|
stream: player.onPositionChanged,
|
|
builder: (context, position) {
|
|
return Slider(
|
|
value: position.hasData && !position.hasError
|
|
? position.data!.inMilliseconds.toDouble()
|
|
: 0,
|
|
onChanged: (value) {
|
|
player.seek(Duration(milliseconds: value.round()));
|
|
},
|
|
min: 0,
|
|
max: duration!.inMilliseconds.toDouble(),
|
|
);
|
|
}),
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
StreamBuilder<Duration>(
|
|
stream: player.onPositionChanged,
|
|
builder: (context, snapshot) {
|
|
return Text(
|
|
snapshot.hasError ||
|
|
!snapshot.hasData && snapshot.data == null
|
|
? '00:00'
|
|
: '${snapshot.data!.inMinutes.remainder(60).toString().padLeft(2, '0')}:${(snapshot.data!.inSeconds.remainder(60)).toString().padLeft(2, '0')}',
|
|
style: AppTextStyles.body6.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface),
|
|
);
|
|
}),
|
|
Text(
|
|
duration == null
|
|
? '00:00'
|
|
: '${duration!.inMinutes.remainder(60).toString().padLeft(2, '0')}:${(duration!.inSeconds.remainder(60)).toString().padLeft(2, '0')}',
|
|
style: AppTextStyles.body6.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface),
|
|
),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
if (widget.url.isURL())
|
|
FutureBuilder(
|
|
future: DioService.getFileSize(widget.url),
|
|
builder: (context, size) {
|
|
if (!size.hasData || size.hasError) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
borderRadius: BorderRadius.circular(16)),
|
|
child: Text(
|
|
'${size.data} MB',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
);
|
|
})
|
|
],
|
|
);
|
|
}
|
|
}
|