From ae9e28b5f29d2e2385e91854d69a1b1f7fbef59b Mon Sep 17 00:00:00 2001 From: Shokhrukhbek Yuldoshev <72590392+ShokhrukhbekYuldoshev@users.noreply.github.com> Date: Mon, 8 Apr 2024 19:28:24 +0300 Subject: [PATCH] Added player bottom app bar drag up to show player controls. Fixed player bottom app bar swipe bugs. Bug fixes and improvements. --- README.md | 1 + lib/main.dart | 2 +- lib/src/bloc/player/player_bloc.dart | 2 +- lib/src/bloc/player/player_event.dart | 3 +- .../data/repositories/player_repository.dart | 13 +- lib/src/presentation/pages/about_page.dart | 5 +- lib/src/presentation/pages/album_page.dart | 89 +--- lib/src/presentation/pages/artist_page.dart | 4 +- .../presentation/pages/favorites_page.dart | 15 +- lib/src/presentation/pages/genre_page.dart | 9 +- .../presentation/pages/home/home_page.dart | 2 +- lib/src/presentation/pages/player_page.dart | 53 +-- .../pages/playlist_details_page.dart | 3 +- lib/src/presentation/pages/queue_page.dart | 3 +- lib/src/presentation/pages/recents_page.dart | 14 +- lib/src/presentation/pages/search_page.dart | 5 +- lib/src/presentation/pages/splash_page.dart | 3 +- .../widgets/player_bottom_app_bar.dart | 430 ++++++++++++------ lib/src/presentation/widgets/seek_bar.dart | 76 ++++ .../presentation/widgets/song_list_tile.dart | 2 +- .../widgets/spinning_disc_animation.dart | 3 +- pubspec.yaml | 2 +- 22 files changed, 430 insertions(+), 309 deletions(-) create mode 100644 lib/src/presentation/widgets/seek_bar.dart diff --git a/README.md b/README.md index 8216990..ac18b20 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Meloplay is a local music player app that plays music from your device built wit - [x] Share music - [x] Settings - [x] Themes (multiple themes) +- [ ] Localization ## 📸 Screenshots diff --git a/lib/main.dart b/lib/main.dart index e47c2ac..418af24 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:meloplay/src/bloc/search/search_bloc.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:meloplay/src/app.dart'; @@ -10,6 +9,7 @@ import 'package:meloplay/src/bloc/favorites/favorites_bloc.dart'; import 'package:meloplay/src/bloc/home/home_bloc.dart'; import 'package:meloplay/src/bloc/player/player_bloc.dart'; import 'package:meloplay/src/bloc/recents/recents_bloc.dart'; +import 'package:meloplay/src/bloc/search/search_bloc.dart'; import 'package:meloplay/src/bloc/song/song_bloc.dart'; import 'package:meloplay/src/bloc/theme/theme_bloc.dart'; import 'package:meloplay/src/core/di/service_locator.dart'; diff --git a/lib/src/bloc/player/player_bloc.dart b/lib/src/bloc/player/player_bloc.dart index e579df6..74aa4ea 100644 --- a/lib/src/bloc/player/player_bloc.dart +++ b/lib/src/bloc/player/player_bloc.dart @@ -49,7 +49,7 @@ class PlayerBloc extends Bloc { on((event, emit) async { try { - await repository.seek(event.position); + await repository.seek(event.position, index: event.index); emit(PlayerSeeked(event.position)); } catch (e) { emit(PlayerError(e.toString())); diff --git a/lib/src/bloc/player/player_event.dart b/lib/src/bloc/player/player_event.dart index 030e208..6c5cc34 100644 --- a/lib/src/bloc/player/player_event.dart +++ b/lib/src/bloc/player/player_event.dart @@ -21,8 +21,9 @@ class PlayerStop extends PlayerEvent {} class PlayerSeek extends PlayerEvent { final Duration position; + final int? index; - PlayerSeek(this.position); + PlayerSeek(this.position, {this.index}); } class PlayerNext extends PlayerEvent {} diff --git a/lib/src/data/repositories/player_repository.dart b/lib/src/data/repositories/player_repository.dart index 4fac586..a1d3b47 100644 --- a/lib/src/data/repositories/player_repository.dart +++ b/lib/src/data/repositories/player_repository.dart @@ -15,7 +15,7 @@ abstract class JustAudioPlayer { Future play(); Future pause(); Future stop(); - Future seek(Duration position); + Future seek(Duration position, {int? index}); Future seekToNext(); Future seekToPrevious(); Stream get position; @@ -144,7 +144,16 @@ class JustAudioPlayerImpl implements JustAudioPlayer { Future stop() => _player.stop(); @override - Future seek(Duration position) => _player.seek(position); + Future seek(Duration position, {int? index}) async { + if (index != null) { + await _player.seek( + position, + index: index, + ); + } else { + await _player.seek(position); + } + } @override Future seekToNext() => _player.seekToNext(); diff --git a/lib/src/presentation/pages/about_page.dart b/lib/src/presentation/pages/about_page.dart index b2acd81..02d3004 100644 --- a/lib/src/presentation/pages/about_page.dart +++ b/lib/src/presentation/pages/about_page.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:meloplay/src/core/theme/themes.dart'; -import 'package:meloplay/src/core/constants/assets.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:meloplay/src/core/constants/assets.dart'; +import 'package:meloplay/src/core/theme/themes.dart'; + class AboutPage extends StatefulWidget { const AboutPage({super.key}); diff --git a/lib/src/presentation/pages/album_page.dart b/lib/src/presentation/pages/album_page.dart index 5b0469e..776a947 100644 --- a/lib/src/presentation/pages/album_page.dart +++ b/lib/src/presentation/pages/album_page.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:marquee/marquee.dart'; +import 'package:on_audio_query/on_audio_query.dart'; + +import 'package:meloplay/src/core/di/service_locator.dart'; import 'package:meloplay/src/core/extensions/string_extensions.dart'; import 'package:meloplay/src/core/helpers/helpers.dart'; +import 'package:meloplay/src/core/theme/themes.dart'; import 'package:meloplay/src/presentation/widgets/player_bottom_app_bar.dart'; import 'package:meloplay/src/presentation/widgets/song_list_tile.dart'; -import 'package:meloplay/src/core/theme/themes.dart'; -import 'package:meloplay/src/core/di/service_locator.dart'; -import 'package:on_audio_query/on_audio_query.dart'; class AlbumPage extends StatefulWidget { final AlbumModel album; @@ -151,88 +152,10 @@ class _AlbumPageState extends State { // margin for bottom app bar const SliverToBoxAdapter( - child: SizedBox(height: 80), + child: SizedBox(height: 60), ), ], - ) - // Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // // back button - // Row( - // children: [ - // IconButton( - // onPressed: () { - // Navigator.of(context).pop(); - // }, - // icon: const Icon(Icons.arrow_back_ios), - // ), - // ], - // ), - // // album artwork - // Expanded( - // child: QueryArtworkWidget( - // id: widget.album.id, - // type: ArtworkType.ALBUM, - // size: 10000, - // artworkWidth: double.infinity, - // artworkBorder: BorderRadius.circular(50), - // nullArtworkWidget: Container( - // width: double.infinity, - // decoration: BoxDecoration( - // color: Colors.grey.withOpacity(0.1), - // borderRadius: BorderRadius.circular(50), - // ), - // child: const Icon( - // Icons.music_note_outlined, - // size: 100, - // ), - // ), - // ), - // ), - // const SizedBox(height: 16), - // // album name - // Padding( - // padding: const EdgeInsets.symmetric(horizontal: 16), - // child: Text( - // widget.album.album, - // style: const TextStyle( - // fontSize: 24, - // fontWeight: FontWeight.bold, - // ), - // ), - // ), - // // artist name - // Padding( - // padding: const EdgeInsets.symmetric(horizontal: 16), - // child: Text( - // widget.album.artist ?? 'Unknown', - // style: const TextStyle( - // fontSize: 18, - // color: Colors.grey, - // ), - // ), - // ), - // const SizedBox(height: 16), - // // songs - // Expanded( - // child: ListView.builder( - // padding: EdgeInsets.zero, - // itemCount: _songs.length, - // itemBuilder: (context, index) { - // final SongModel song = _songs[index]; - - // return SongListTile( - // song: song, - // songs: _songs, - // showAlbumArt: false, - // ); - // }, - // ), - // ), - // ], - // ), - ), + )), ); } } diff --git a/lib/src/presentation/pages/artist_page.dart b/lib/src/presentation/pages/artist_page.dart index 0e075af..26944ff 100644 --- a/lib/src/presentation/pages/artist_page.dart +++ b/lib/src/presentation/pages/artist_page.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:marquee/marquee.dart'; -import 'package:meloplay/src/core/helpers/helpers.dart'; import 'package:on_audio_query/on_audio_query.dart'; import 'package:meloplay/src/core/di/service_locator.dart'; import 'package:meloplay/src/core/extensions/string_extensions.dart'; +import 'package:meloplay/src/core/helpers/helpers.dart'; import 'package:meloplay/src/core/theme/themes.dart'; import 'package:meloplay/src/presentation/widgets/player_bottom_app_bar.dart'; import 'package:meloplay/src/presentation/widgets/song_list_tile.dart'; @@ -151,7 +151,7 @@ class _ArtistPageState extends State { // margin for bottom app bar const SliverToBoxAdapter( - child: SizedBox(height: 80), + child: SizedBox(height: 60), ), ], ), diff --git a/lib/src/presentation/pages/favorites_page.dart b/lib/src/presentation/pages/favorites_page.dart index 19821ec..0981c7a 100644 --- a/lib/src/presentation/pages/favorites_page.dart +++ b/lib/src/presentation/pages/favorites_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + import 'package:meloplay/src/bloc/favorites/favorites_bloc.dart'; import 'package:meloplay/src/bloc/song/song_bloc.dart'; import 'package:meloplay/src/core/theme/themes.dart'; @@ -71,18 +72,12 @@ class _FavoritesPageState extends State { ); } return ListView.builder( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(bottom: 60), itemCount: state.favoriteSongs.length, itemBuilder: (context, index) { - return Column( - children: [ - SongListTile( - song: state.favoriteSongs[index], - songs: state.favoriteSongs, - ), - if (index == state.favoriteSongs.length - 1) - const SizedBox(height: 80), - ], + return SongListTile( + song: state.favoriteSongs[index], + songs: state.favoriteSongs, ); }, ); diff --git a/lib/src/presentation/pages/genre_page.dart b/lib/src/presentation/pages/genre_page.dart index 29e8776..bf874ca 100644 --- a/lib/src/presentation/pages/genre_page.dart +++ b/lib/src/presentation/pages/genre_page.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:on_audio_query/on_audio_query.dart'; + +import 'package:meloplay/src/core/di/service_locator.dart'; +import 'package:meloplay/src/core/theme/themes.dart'; import 'package:meloplay/src/presentation/widgets/player_bottom_app_bar.dart'; import 'package:meloplay/src/presentation/widgets/song_list_tile.dart'; -import 'package:meloplay/src/core/theme/themes.dart'; -import 'package:meloplay/src/core/di/service_locator.dart'; -import 'package:on_audio_query/on_audio_query.dart'; class GenrePage extends StatefulWidget { final GenreModel genre; @@ -71,6 +72,8 @@ class _GenrePageState extends State { }, ), ), + // margin for bottom app bar + const SizedBox(height: 60), ], ), ), diff --git a/lib/src/presentation/pages/home/home_page.dart b/lib/src/presentation/pages/home/home_page.dart index f8fa7d2..d69a044 100644 --- a/lib/src/presentation/pages/home/home_page.dart +++ b/lib/src/presentation/pages/home/home_page.dart @@ -60,6 +60,7 @@ class _HomePageState extends State with TickerProviderStateMixin { // current song, play/pause button, song progress bar, song queue button bottomNavigationBar: const PlayerBottomAppBar(), extendBody: true, + backgroundColor: Themes.getTheme().secondaryColor, drawer: _buildDrawer(context), appBar: _buildAppBar(), body: _buildBody(context), @@ -123,7 +124,6 @@ class _HomePageState extends State with TickerProviderStateMixin { actions: [ IconButton( onPressed: () { - // TODO: implement search Navigator.of(context).pushNamed(AppRouter.searchRoute); }, icon: const Icon(Icons.search_outlined), diff --git a/lib/src/presentation/pages/player_page.dart b/lib/src/presentation/pages/player_page.dart index 4e9f7db..5dc9c41 100644 --- a/lib/src/presentation/pages/player_page.dart +++ b/lib/src/presentation/pages/player_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; +import 'package:meloplay/src/presentation/widgets/seek_bar.dart'; import 'package:on_audio_query/on_audio_query.dart'; import 'package:meloplay/src/bloc/player/player_bloc.dart'; @@ -171,57 +172,7 @@ class _PlayerPageState extends State { const Spacer(), // seek bar - StreamBuilder( - stream: player.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - return StreamBuilder( - stream: player.duration, - builder: (context, snapshot) { - final duration = snapshot.data ?? Duration.zero; - return Column( - children: [ - Slider( - value: position > duration - ? duration.inMilliseconds.toDouble() - : position.inMilliseconds.toDouble(), - min: 0, - max: duration.inMilliseconds.toDouble(), - onChanged: (value) { - context.read().add( - PlayerSeek( - Duration(milliseconds: value.toInt()), - ), - ); - }, - ), - - // position and duration text - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${position.inMinutes.toString().padLeft(2, '0')}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Text( - '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - ); - }, - ); - }, - ), + SeekBar(player: player), const Spacer(), Row( diff --git a/lib/src/presentation/pages/playlist_details_page.dart b/lib/src/presentation/pages/playlist_details_page.dart index 76630a6..a34afed 100644 --- a/lib/src/presentation/pages/playlist_details_page.dart +++ b/lib/src/presentation/pages/playlist_details_page.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:meloplay/src/core/theme/themes.dart'; import 'package:on_audio_query/on_audio_query.dart'; +import 'package:meloplay/src/core/theme/themes.dart'; + class PlaylistDetailsPage extends StatefulWidget { final PlaylistModel playlist; const PlaylistDetailsPage({super.key, required this.playlist}); diff --git a/lib/src/presentation/pages/queue_page.dart b/lib/src/presentation/pages/queue_page.dart index acaa70e..d7739d4 100644 --- a/lib/src/presentation/pages/queue_page.dart +++ b/lib/src/presentation/pages/queue_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + import 'package:meloplay/src/core/di/service_locator.dart'; import 'package:meloplay/src/core/theme/themes.dart'; import 'package:meloplay/src/data/repositories/player_repository.dart'; @@ -34,7 +35,7 @@ class _QueuePageState extends State { final playlist = sl().playlist; return ListView.builder( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(bottom: 60), itemCount: playlist.length, itemBuilder: (context, index) { return SongListTile( diff --git a/lib/src/presentation/pages/recents_page.dart b/lib/src/presentation/pages/recents_page.dart index 0db1ff8..df7164a 100644 --- a/lib/src/presentation/pages/recents_page.dart +++ b/lib/src/presentation/pages/recents_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + import 'package:meloplay/src/bloc/recents/recents_bloc.dart'; import 'package:meloplay/src/bloc/song/song_bloc.dart'; import 'package:meloplay/src/core/theme/themes.dart'; @@ -71,17 +72,12 @@ class _RecentsPageState extends State { ); } return ListView.builder( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(bottom: 60), itemCount: state.songs.length, itemBuilder: (context, index) { - return Column( - children: [ - SongListTile( - song: state.songs[index], - songs: state.songs, - ), - if (index == state.songs.length - 1) const SizedBox(height: 80), - ], + return SongListTile( + song: state.songs[index], + songs: state.songs, ); }, ); diff --git a/lib/src/presentation/pages/search_page.dart b/lib/src/presentation/pages/search_page.dart index 09b4f68..abb5f79 100644 --- a/lib/src/presentation/pages/search_page.dart +++ b/lib/src/presentation/pages/search_page.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:on_audio_query/on_audio_query.dart'; + import 'package:meloplay/src/bloc/search/search_bloc.dart'; import 'package:meloplay/src/core/extensions/string_extensions.dart'; import 'package:meloplay/src/core/theme/themes.dart'; import 'package:meloplay/src/presentation/widgets/song_list_tile.dart'; -import 'package:on_audio_query/on_audio_query.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -19,6 +20,7 @@ class _SearchPageState extends State { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: Themes.getTheme().secondaryColor, appBar: AppBar( elevation: 0, backgroundColor: Themes.getTheme().primaryColor, @@ -272,6 +274,7 @@ class _SearchPageState extends State { ), ], ), + const SizedBox(height: 60), ], ), ), diff --git a/lib/src/presentation/pages/splash_page.dart b/lib/src/presentation/pages/splash_page.dart index 7d01884..75d452f 100644 --- a/lib/src/presentation/pages/splash_page.dart +++ b/lib/src/presentation/pages/splash_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:meloplay/src/core/router/app_router.dart'; + import 'package:meloplay/src/core/constants/assets.dart'; +import 'package:meloplay/src/core/router/app_router.dart'; import 'package:meloplay/src/core/theme/themes.dart'; class SplashPage extends StatefulWidget { diff --git a/lib/src/presentation/widgets/player_bottom_app_bar.dart b/lib/src/presentation/widgets/player_bottom_app_bar.dart index d0e21c2..7680ae2 100644 --- a/lib/src/presentation/widgets/player_bottom_app_bar.dart +++ b/lib/src/presentation/widgets/player_bottom_app_bar.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:just_audio/just_audio.dart'; @@ -9,7 +11,9 @@ import 'package:meloplay/src/core/di/service_locator.dart'; import 'package:meloplay/src/core/router/app_router.dart'; import 'package:meloplay/src/core/theme/themes.dart'; import 'package:meloplay/src/data/repositories/player_repository.dart'; +import 'package:meloplay/src/presentation/widgets/seek_bar.dart'; import 'package:meloplay/src/presentation/widgets/spinning_disc_animation.dart'; +import 'package:on_audio_query/on_audio_query.dart'; class PlayerBottomAppBar extends StatefulWidget { const PlayerBottomAppBar({ @@ -23,6 +27,7 @@ class PlayerBottomAppBar extends StatefulWidget { class _PlayerBottomAppBarState extends State { final player = sl(); bool isPlaying = false; + bool isExpanded = false; @override void initState() { @@ -46,94 +51,57 @@ class _PlayerBottomAppBarState extends State { return SizedBox( child: BlocBuilder( builder: (context, state) { - return BlocBuilder( - builder: (context, state) { - return StreamBuilder( - stream: player.sequenceState, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox.shrink(); - } + return StreamBuilder( + stream: player.sequenceState, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } - final sequence = snapshot.data; - MediaItem mediaItem = - sequence?.sequence[sequence.currentIndex].tag; + final sequence = snapshot.data; + MediaItem mediaItem = + sequence?.sequence[sequence.currentIndex].tag; - final pageController = PageController( - initialPage: sequence?.currentIndex ?? 0, - ); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () async { - Navigator.of(context).pushNamed( - AppRouter.playerRoute, - ); - }, - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular( - 32, - ), - topRight: Radius.circular( - 32, - ), - ), - child: BottomAppBar( - height: 60, - color: Themes.getTheme().primaryColor, - padding: const EdgeInsets.all(0), - child: Row( - children: [ - const SizedBox(width: 20), - // song info with swiping - Expanded( - child: StreamBuilder( - stream: player.currentIndex, - builder: (context, snapshot) { - final currentIndex = snapshot.data ?? 0; - return SwipeSong( - sequence: sequence, - pageController: pageController, - currentIndex: currentIndex, - mediaItem: mediaItem, - ); - }, - ), - ), - // play/pause button - StreamBuilder( - stream: player.playing, - builder: (context, snapshot) { - final playing = snapshot.data ?? false; - return IconButton( - onPressed: () async { - if (playing) { - await player.pause(); - } else { - await player.play(); - } - }, - icon: playing - ? const Icon(Icons.pause_outlined) - : const Icon(Icons.play_arrow_outlined), - ); - }, - ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - AppRouter.queueRoute, - ); - }, - icon: const Icon(Icons.queue_music_outlined), - ), - const SizedBox(width: 20), - ], - ), - ), - ), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + Navigator.of(context).pushNamed( + AppRouter.playerRoute, ); }, + // slide up to show player + onVerticalDragUpdate: (details) { + bool previousIsExpanded = isExpanded; + if (details.delta.dy > 0) { + isExpanded = false; + } else { + isExpanded = true; + } + if (previousIsExpanded != isExpanded) { + setState(() {}); + } + }, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular( + 32, + ), + topRight: Radius.circular( + 32, + ), + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: isExpanded ? 264 : 60, + child: BottomAppBar( + color: Themes.getTheme().primaryColor, + padding: const EdgeInsets.all(0), + child: isExpanded + ? _buildExpanded(sequence!, mediaItem) + : _buildCollapsed(sequence!, mediaItem), + ), + ), + ), ); }, ); @@ -141,77 +109,267 @@ class _PlayerBottomAppBarState extends State { ), ); } + + _buildExpanded(SequenceState sequence, MediaItem mediaItem) { + return Stack( + children: [ + QueryArtworkWidget( + keepOldArtwork: true, + artworkHeight: double.infinity, + id: int.parse(mediaItem.id), + type: ArtworkType.AUDIO, + size: 10000, + artworkWidth: double.infinity, + artworkBorder: BorderRadius.circular(0), + nullArtworkWidget: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(0), + ), + child: const Icon( + Icons.music_note_outlined, + size: 100, + ), + ), + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(0), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 32, 16, 32), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + Text( + mediaItem.title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + mediaItem.artist ?? 'Unknown', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + const SizedBox(height: 20), + SeekBar(player: player, isWhite: true), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // previous button + IconButton( + onPressed: () { + context + .read() + .add(bloc.PlayerPrevious()); + }, + icon: const Icon( + Icons.skip_previous_outlined, + color: Colors.white, + ), + iconSize: 40, + ), + const SizedBox(width: 20), + // play/pause button + StreamBuilder( + stream: player.playing, + builder: (context, snapshot) { + final playing = snapshot.data ?? false; + return IconButton( + onPressed: () { + if (playing) { + context + .read() + .add(bloc.PlayerPause()); + } else { + context + .read() + .add(bloc.PlayerPlay()); + } + }, + icon: playing + ? const Icon( + Icons.pause_outlined, + color: Colors.white, + ) + : const Icon( + Icons.play_arrow_outlined, + color: Colors.white, + ), + iconSize: 40, + ); + }, + ), + const SizedBox(width: 20), + // next button + IconButton( + onPressed: () { + context.read().add(bloc.PlayerNext()); + }, + icon: const Icon( + Icons.skip_next_outlined, + color: Colors.white, + ), + iconSize: 40, + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + _buildCollapsed(SequenceState sequence, MediaItem mediaItem) { + return Row( + children: [ + const SizedBox(width: 20), + // song info with swiping + Expanded( + child: SwipeSong( + sequence: sequence, + mediaItem: mediaItem, + ), + ), + // play/pause button + StreamBuilder( + stream: player.playing, + builder: (context, snapshot) { + final playing = snapshot.data ?? false; + return IconButton( + onPressed: () async { + if (playing) { + await player.pause(); + } else { + await player.play(); + } + }, + icon: playing + ? const Icon(Icons.pause_outlined) + : const Icon(Icons.play_arrow_outlined), + ); + }, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + AppRouter.queueRoute, + ); + }, + icon: const Icon(Icons.queue_music_outlined), + ), + const SizedBox(width: 20), + ], + ); + } } -class SwipeSong extends StatelessWidget { +class SwipeSong extends StatefulWidget { const SwipeSong({ super.key, required this.sequence, - required this.pageController, - required this.currentIndex, required this.mediaItem, }); final SequenceState? sequence; - final PageController pageController; - final int currentIndex; final MediaItem mediaItem; + @override + State createState() => _SwipeSongState(); +} + +class _SwipeSongState extends State { + late PageController pageController; + + @override + void initState() { + super.initState(); + pageController = PageController( + initialPage: widget.sequence?.currentIndex ?? 0, + ); + } + @override Widget build(BuildContext context) { - return PageView.builder( - itemCount: sequence?.sequence.length, - controller: pageController, - onPageChanged: (index) async { - // if swiped right to left (next song) - if (index > currentIndex) { - context.read().add(bloc.PlayerNext()); - } - // if swiped left to right (previous song) - else if (index < currentIndex) { - context.read().add(bloc.PlayerPrevious()); + return BlocListener( + listener: (context, state) { + if (widget.sequence?.currentIndex != pageController.page?.round()) { + pageController.jumpToPage( + widget.sequence?.currentIndex ?? 0, + ); } }, - itemBuilder: (context, index) { - return Row( - children: [ - SpinningDisc( - id: int.parse( - mediaItem.id, + child: PageView.builder( + itemCount: widget.sequence?.sequence.length ?? 0, + controller: pageController, + onPageChanged: (index) { + if (widget.sequence?.currentIndex != index) { + context.read().add( + bloc.PlayerSeek( + Duration.zero, + index: index, + ), + ); + } + }, + itemBuilder: (context, index) { + MediaItem mediaItem = widget.sequence?.sequence[index].tag; + return Row( + children: [ + SpinningDisc( + id: int.parse(mediaItem.id), ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - mediaItem.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.bold, + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + mediaItem.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), - ), - Text( - mediaItem.artist ?? 'Unknown', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Themes.getTheme() - .colorScheme - .onSurface - .withOpacity(0.7), + Text( + mediaItem.artist ?? 'Unknown', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Themes.getTheme() + .colorScheme + .onSurface + .withOpacity(0.7), + ), ), - ), - ], + ], + ), ), - ), - const SizedBox(width: 16), - ], - ); - }, + const SizedBox(width: 16), + ], + ); + }, + ), ); } } diff --git a/lib/src/presentation/widgets/seek_bar.dart b/lib/src/presentation/widgets/seek_bar.dart new file mode 100644 index 0000000..13878e0 --- /dev/null +++ b/lib/src/presentation/widgets/seek_bar.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:meloplay/src/bloc/player/player_bloc.dart'; +import 'package:meloplay/src/data/repositories/player_repository.dart'; + +class SeekBar extends StatelessWidget { + const SeekBar({ + super.key, + required this.player, + this.isWhite = false, + }); + + final JustAudioPlayer player; + final bool isWhite; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: player.position, + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + return StreamBuilder( + stream: player.duration, + builder: (context, snapshot) { + final duration = snapshot.data ?? Duration.zero; + return Column( + children: [ + Slider( + value: position > duration + ? duration.inMilliseconds.toDouble() + : position.inMilliseconds.toDouble(), + min: 0, + max: duration.inMilliseconds.toDouble(), + onChanged: (value) { + context.read().add( + PlayerSeek( + Duration(milliseconds: value.toInt()), + ), + ); + }, + ), + + // position and duration text + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${position.inMinutes.toString().padLeft(2, '0')}:${(position.inSeconds % 60).toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isWhite + ? Colors.white + : Theme.of(context).textTheme.bodyMedium!.color, + ), + ), + Text( + '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isWhite + ? Colors.white + : Theme.of(context).textTheme.bodyMedium!.color, + ), + ), + ], + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/presentation/widgets/song_list_tile.dart b/lib/src/presentation/widgets/song_list_tile.dart index dc420d3..a00847c 100644 --- a/lib/src/presentation/widgets/song_list_tile.dart +++ b/lib/src/presentation/widgets/song_list_tile.dart @@ -5,11 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; import 'package:lottie/lottie.dart'; -import 'package:meloplay/src/bloc/player/player_bloc.dart'; import 'package:on_audio_query/on_audio_query.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:meloplay/src/bloc/player/player_bloc.dart'; import 'package:meloplay/src/bloc/song/song_bloc.dart'; import 'package:meloplay/src/core/constants/assets.dart'; import 'package:meloplay/src/core/di/service_locator.dart'; diff --git a/lib/src/presentation/widgets/spinning_disc_animation.dart b/lib/src/presentation/widgets/spinning_disc_animation.dart index d633d35..37e45bf 100644 --- a/lib/src/presentation/widgets/spinning_disc_animation.dart +++ b/lib/src/presentation/widgets/spinning_disc_animation.dart @@ -1,9 +1,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:on_audio_query/on_audio_query.dart'; + import 'package:meloplay/src/core/di/service_locator.dart'; import 'package:meloplay/src/data/repositories/player_repository.dart'; -import 'package:on_audio_query/on_audio_query.dart'; class SpinningDisc extends StatefulWidget { final int id; diff --git a/pubspec.yaml b/pubspec.yaml index 657c5c9..2c45274 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: meloplay description: 'Meloplay is a local music player app that plays music from your device built with Flutter.' publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.2.3+1 +version: 1.2.4+1 environment: sdk: '>=3.3.3 <4.0.0'