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:music_web_player/ui/player_web_interface.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:video_player/video_player.dart'; extension DurationExt on Duration { String get formatted { return "$inMinutes:${(inSeconds % 60).toString().padLeft(2, '0')}"; } } const smartphoneSize = 700; class MusicPlayer extends StatefulWidget { final MusicsList musicsList; const MusicPlayer({Key? key, required this.musicsList}) : super(key: key); @override State createState() => _MusicPlayerState(); } class _MusicPlayerState extends State { 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 _stack = []; int currMusicPos = 0; var _showPlaylist = true; var _playFilteredMusics = 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 list = _playFilteredMusics ? _filteredList ?? widget.musicsList : widget.musicsList; var nextId = rng.nextInt(list.length); _stack.add(list[nextId]); } return _stack[currMusicPos]; } Future _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(); } } })); updateMusicSession( entry: currMusic, onPlay: _play, onPause: _pause, onNextTrack: _playNext, onPreviousTrack: _playPrevious, onStop: _stop, ); } } Future _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) { if (!_showPlaylist) { return _playerWithoutPlaylistPane(); } else { return _playerWithPlaylistPane(); } } Widget _playerWithoutPlaylistPane() => _buildWithBackground(LayoutBuilder( builder: (context, constraints) { return fluent.Row( children: [ SizedBox( width: constraints.maxWidth, child: Stack( children: [ fluent.SizedBox( width: constraints.maxWidth, child: _buildPlayerWidget(), ), Positioned( top: 10, right: 10, child: fluent.Row( children: [ _buildToggleListButton(), ], )), Positioned( bottom: 10, right: 10, child: fluent.Row( children: [ _buildDownloadTrackButton(), const SizedBox(width: 10), _buildAboutButton(), ], )), ], ), ), ], ); }, )); Widget _playerWithPlaylistPane() => LayoutBuilder( builder: (context, constraints) { const double playerSize = 100; return Column( children: [ fluent.SizedBox( height: constraints.maxHeight - playerSize, child: _buildPlaylistPane(), ), fluent.SizedBox( height: playerSize, width: constraints.maxWidth, child: _buildWithBackground( _buildSmallPlayerWidget(constraints.maxWidth)), ), ], ); }, ); Widget _buildWithBackground(Widget child) => LayoutBuilder( builder: (context, constraints) { return fluent.Row( children: [ SizedBox( width: constraints.maxWidth, child: Stack( children: [ // Background image CoverImage( music: currMusic, width: constraints.maxWidth, 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: constraints.maxWidth, height: constraints.maxHeight, ), ), ), ), child ], ), ), ], ); }, ); Widget _buildPlayerWidget() => fluent.Center( child: SizedBox( width: 250, child: Column( mainAxisAlignment: fluent.MainAxisAlignment.center, crossAxisAlignment: fluent.CrossAxisAlignment.center, children: [ RoundedImage( 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), _buildProgressBar(), const SizedBox(width: 15), DurationText(_duration), ], ), const fluent.SizedBox(height: 40), _buildPlayersIcons(), ], ), ), ); Widget _buildSmallPlayerWidget(double width) { var partSize = width > smartphoneSize ? width / 3 : width / 2.5; return Padding( padding: const EdgeInsets.all(16.0), child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ fluent.ClipRect( child: SizedBox( width: partSize, child: Row( children: [ RoundedImage( child: CoverImage( music: currMusic, width: 80, height: 80, backgroundColor: Colors.black, fit: BoxFit.cover, ), ), // Artist area const SizedBox(width: 10), fluent.ClipRect( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( currMusic.title, style: const TextStyle(fontSize: 22), overflow: TextOverflow.ellipsis, ), Text( currMusic.artist, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, ), ], ), ) ], ), ), ), SizedBox( width: partSize, child: Column( children: [ fluent.Row( children: [ DurationText(_position), const SizedBox(width: 20), _buildProgressBar(), const SizedBox(width: 20), DurationText(_duration), ], ), const SizedBox(height: 5), _buildPlayersIcons(25), ], )), const Spacer(), _buildToggleListButton() ], ), ); } Widget _buildPlayersIcons([double? size]) => fluent.Row( children: [ IconButton( icon: PlayerIcon( fluent.FluentIcons.previous, size: size, ), onPressed: currMusicPos == 0 ? null : _playPrevious, ), const Spacer(), IconButton( icon: PlayerIcon( _isPlaying ? fluent.FluentIcons.pause : fluent.FluentIcons.play, size: size, ), onPressed: _isPlaying ? _pause : _play, ), const Spacer(), IconButton( icon: PlayerIcon( fluent.FluentIcons.next, size: size, ), onPressed: _playNext, ), ], ); Widget _buildProgressBar() => 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, ), ); Widget _buildDownloadTrackButton() => fluent.Button( onPressed: () => launchUrlString(currMusic.musicURL, webOnlyWindowName: "_blank"), child: const fluent.Icon(fluent.FluentIcons.download), ); Widget _buildAboutButton() => fluent.Button( onPressed: () => showLicensePage( context: context, applicationName: "Music Player", applicationIcon: const fluent.Icon(FluentIcons.music_note_2_24_regular), ), child: const fluent.Icon(fluent.FluentIcons.info), ); 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( leading: RoundedImage( child: CoverImage( delayLoading: const Duration(seconds: 2), music: music, width: 50, height: 50, fit: BoxFit.cover, backgroundColor: Colors.transparent, icon: const Icon(Icons.music_note), ), ), title: Text(music.title), subtitle: Text(music.artist), selected: currMusic == music, onTap: () => _playMusic(music), ); }, itemCount: (_filteredList ?? widget.musicsList).length, ), ), (_filteredList?.length ?? 0) < 2 ? Container() : ListTile( leading: fluent.ToggleSwitch( checked: _playFilteredMusics, onChanged: (v) => setState(() => _playFilteredMusics = v), ), title: const Text("Play only filtered musics"), ) ], ); } } class PlayerIcon extends StatelessWidget { final IconData icon; final double? size; const PlayerIcon(this.icon, {Key? key, this.size}) : super(key: key); @override Widget build(BuildContext context) => Icon(icon, size: 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), ); } } class RoundedImage extends StatelessWidget { final Widget child; const RoundedImage({Key? key, required this.child}) : super(key: key); @override Widget build(BuildContext context) { return Material( borderRadius: const BorderRadius.all( Radius.circular(18.0), ), clipBehavior: Clip.hardEdge, child: child, ); } }