338 lines
9.8 KiB
Dart
338 lines
9.8 KiB
Dart
import 'dart:math';
|
|
import 'dart:ui';
|
|
|
|
import 'package:chewie_audio/chewie_audio.dart';
|
|
import 'package:fluent_ui/fluent_ui.dart' as fluent;
|
|
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:music_web_player/api.dart';
|
|
import 'package:music_web_player/ui/cover_image.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
extension DurationExt on Duration {
|
|
String get formatted {
|
|
return "$inMinutes:${(inSeconds % 60).toString().padLeft(2, '0')}";
|
|
}
|
|
}
|
|
|
|
const double playlistWidth = 300;
|
|
|
|
class MusicPlayer extends StatefulWidget {
|
|
final MusicsList musicsList;
|
|
|
|
const MusicPlayer({Key? key, required this.musicsList}) : super(key: key);
|
|
|
|
@override
|
|
State<MusicPlayer> createState() => _MusicPlayerState();
|
|
}
|
|
|
|
class _MusicPlayerState extends State<MusicPlayer> {
|
|
final rng = Random();
|
|
|
|
VideoPlayerController? _videoPlayerController;
|
|
ChewieAudioController? _chewieAudioController;
|
|
|
|
bool get _isPlaying => _videoPlayerController?.value.isPlaying ?? false;
|
|
|
|
Duration? get _duration => _videoPlayerController?.value.duration;
|
|
|
|
Duration? get _position => _videoPlayerController?.value.position;
|
|
|
|
final List<MusicEntry> _stack = [];
|
|
int currMusicPos = 0;
|
|
|
|
var _showPlaylist = false;
|
|
|
|
final _filterController = fluent.TextEditingController();
|
|
MusicsList? _filteredList;
|
|
|
|
MusicEntry get currMusic {
|
|
if (currMusicPos < 0) currMusicPos = 0;
|
|
|
|
// Automatically choose next music if required
|
|
if (currMusicPos >= _stack.length) {
|
|
var nextId = rng.nextInt(widget.musicsList.length);
|
|
_stack.add(widget.musicsList[nextId]);
|
|
}
|
|
|
|
return _stack[currMusicPos];
|
|
}
|
|
|
|
Future<void> _play() async {
|
|
if (_chewieAudioController != null) {
|
|
await _chewieAudioController!.play();
|
|
} else {
|
|
_videoPlayerController =
|
|
VideoPlayerController.network(currMusic.musicURL);
|
|
await _videoPlayerController!.initialize();
|
|
_chewieAudioController = ChewieAudioController(
|
|
videoPlayerController: _videoPlayerController!,
|
|
autoPlay: true,
|
|
showControls: false);
|
|
|
|
_videoPlayerController!.addListener(() => setState(() {
|
|
// Automatically play next music if required
|
|
if (_videoPlayerController != null && _duration == _position) {
|
|
if (_duration?.inSeconds == _position?.inSeconds &&
|
|
(_duration?.inSeconds ?? 0) > 0) {
|
|
_playNext();
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
Future<void> _stop() async {
|
|
_chewieAudioController?.dispose();
|
|
_videoPlayerController?.dispose();
|
|
|
|
_chewieAudioController = null;
|
|
_videoPlayerController = null;
|
|
}
|
|
|
|
void _pause() async {
|
|
await _chewieAudioController?.pause();
|
|
}
|
|
|
|
void _playPrevious() async {
|
|
currMusicPos -= 1;
|
|
await _stop();
|
|
await _play();
|
|
}
|
|
|
|
void _playNext() async {
|
|
currMusicPos += 1;
|
|
await _stop();
|
|
await _play();
|
|
}
|
|
|
|
void _playMusic(MusicEntry music) async {
|
|
_stack.insert(currMusicPos + 1, music);
|
|
_playNext();
|
|
}
|
|
|
|
void _updatePosition(Duration d) => _videoPlayerController?.seekTo(d);
|
|
|
|
void _refreshFilteredList() {
|
|
final value = _filterController.value.text.toLowerCase();
|
|
if (value.isEmpty) {
|
|
setState(() => _filteredList = null);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_filteredList = widget.musicsList
|
|
.where((m) => m.fullName.toLowerCase().contains(value))
|
|
.toList();
|
|
});
|
|
}
|
|
|
|
void _clearFilter() {
|
|
_filterController.text = "";
|
|
_refreshFilteredList();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_stop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final mainAreaWidth =
|
|
constraints.maxWidth - (_showPlaylist ? playlistWidth : 0);
|
|
|
|
return fluent.Row(
|
|
children: [
|
|
SizedBox(
|
|
width: mainAreaWidth,
|
|
child: Stack(
|
|
children: [
|
|
// Background image
|
|
CoverImage(
|
|
music: currMusic,
|
|
width: mainAreaWidth,
|
|
height: constraints.maxHeight,
|
|
fit: BoxFit.cover,
|
|
),
|
|
|
|
// Blur background image
|
|
ClipRRect(
|
|
// Clip it cleanly.
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
child: Container(
|
|
color: Colors.black.withOpacity(0.8),
|
|
alignment: Alignment.center,
|
|
child: SizedBox(
|
|
width: mainAreaWidth,
|
|
height: constraints.maxHeight,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
fluent.SizedBox(
|
|
width: mainAreaWidth,
|
|
child: _buildPlayerWidget(),
|
|
),
|
|
|
|
Positioned(
|
|
top: 10, right: 10, child: _buildToggleListButton()),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Playlist
|
|
_showPlaylist
|
|
? SizedBox(
|
|
width: playlistWidth,
|
|
height: constraints.maxHeight,
|
|
child: _buildPlaylistPane(),
|
|
)
|
|
: Container(),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPlayerWidget() => fluent.Center(
|
|
child: SizedBox(
|
|
width: 250,
|
|
child: Column(
|
|
mainAxisAlignment: fluent.MainAxisAlignment.center,
|
|
crossAxisAlignment: fluent.CrossAxisAlignment.center,
|
|
children: [
|
|
Material(
|
|
borderRadius: const BorderRadius.all(
|
|
Radius.circular(18.0),
|
|
),
|
|
clipBehavior: Clip.hardEdge,
|
|
child: CoverImage(
|
|
width: 250,
|
|
height: 250,
|
|
music: currMusic,
|
|
fit: BoxFit.cover,
|
|
backgroundColor: Colors.black12,
|
|
icon:
|
|
const Icon(FluentIcons.music_note_2_24_regular, size: 90),
|
|
),
|
|
),
|
|
const SizedBox(height: 40),
|
|
Text(
|
|
currMusic.title,
|
|
style: const TextStyle(fontSize: 22),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(currMusic.artist, textAlign: TextAlign.center),
|
|
const fluent.SizedBox(height: 40),
|
|
fluent.Row(
|
|
children: [
|
|
DurationText(_position),
|
|
const SizedBox(width: 15),
|
|
Flexible(
|
|
child: fluent.Slider(
|
|
max: _duration?.inSeconds as double? ?? 0,
|
|
value: _position?.inSeconds as double? ?? 0,
|
|
onChanged: (d) =>
|
|
_updatePosition(Duration(seconds: d.toInt())),
|
|
label: _position?.formatted,
|
|
),
|
|
),
|
|
const SizedBox(width: 15),
|
|
DurationText(_duration),
|
|
],
|
|
),
|
|
const fluent.SizedBox(height: 40),
|
|
fluent.Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const PlayerIcon(fluent.FluentIcons.previous),
|
|
onPressed: currMusicPos == 0 ? null : _playPrevious,
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: PlayerIcon(_isPlaying
|
|
? fluent.FluentIcons.pause
|
|
: fluent.FluentIcons.play),
|
|
onPressed: _isPlaying ? _pause : _play,
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const PlayerIcon(fluent.FluentIcons.next),
|
|
onPressed: _playNext,
|
|
),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _buildToggleListButton() => fluent.ToggleButton(
|
|
checked: _showPlaylist,
|
|
onChanged: (s) => setState(() => _showPlaylist = s),
|
|
child: const fluent.Icon(fluent.FluentIcons.playlist_music),
|
|
);
|
|
|
|
Widget _buildPlaylistPane() {
|
|
return Column(
|
|
children: [
|
|
fluent.TextBox(
|
|
controller: _filterController,
|
|
placeholder: "Filter list...",
|
|
onChanged: (s) => _refreshFilteredList(),
|
|
suffix: _filterController.text.isEmpty
|
|
? null
|
|
: fluent.IconButton(
|
|
icon: const Icon(fluent.FluentIcons.clear),
|
|
onPressed: _clearFilter,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: ListView.builder(
|
|
itemBuilder: (c, i) {
|
|
final music = (_filteredList ?? widget.musicsList)[i];
|
|
return ListTile(
|
|
title: Text(music.title),
|
|
subtitle: Text(music.artist),
|
|
selected: currMusic == music,
|
|
onTap: () => _playMusic(music),
|
|
);
|
|
},
|
|
itemCount: (_filteredList ?? widget.musicsList).length,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class PlayerIcon extends StatelessWidget {
|
|
final IconData icon;
|
|
|
|
const PlayerIcon(this.icon, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Icon(icon, size: 35);
|
|
}
|
|
|
|
class DurationText extends StatelessWidget {
|
|
final Duration? duration;
|
|
|
|
const DurationText(this.duration, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Text(
|
|
duration?.formatted ?? "0:00",
|
|
style: const TextStyle(fontSize: 10),
|
|
);
|
|
}
|
|
}
|