diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87bcb21..eab46c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,14 @@ jobs: if: matrix.os == 'ubuntu-latest' run: sudo apt-get install -y libgtk-3-dev pkg-config + - name: Install Unwind (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install libunwind-dev + + - name: Install GStreamer (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + - name: Setup Flutter uses: subosito/flutter-action@v2 with: diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cb36edb..2cd451d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + { + List withGapY({double height = 15}) { + List children = toList(); + List result = []; + for (var child in children) { + result.add(child); + result.add(SizedBox(height: height)); + } + return result; + } +} diff --git a/lib/models/album_model.dart b/lib/core/models/album_model.dart similarity index 100% rename from lib/models/album_model.dart rename to lib/core/models/album_model.dart diff --git a/lib/core/models/lyrics_model.dart b/lib/core/models/lyrics_model.dart new file mode 100644 index 0000000..53f44e7 --- /dev/null +++ b/lib/core/models/lyrics_model.dart @@ -0,0 +1,12 @@ +class Lyrics { + final String lyrics; + final String copyright; + + Lyrics({required this.copyright, required this.lyrics}); + + factory Lyrics.fromJson(Map json) { + return Lyrics( + copyright: json['data']['copyright'], lyrics: json['data']['lyrics']); + } + +} diff --git a/lib/models/song_download_model.dart b/lib/core/models/song_download_model.dart similarity index 100% rename from lib/models/song_download_model.dart rename to lib/core/models/song_download_model.dart diff --git a/lib/models/song_image_model.dart b/lib/core/models/song_image_model.dart similarity index 100% rename from lib/models/song_image_model.dart rename to lib/core/models/song_image_model.dart diff --git a/lib/models/song_model.dart b/lib/core/models/song_model.dart similarity index 84% rename from lib/models/song_model.dart rename to lib/core/models/song_model.dart index 64a8649..cd37091 100644 --- a/lib/models/song_model.dart +++ b/lib/core/models/song_model.dart @@ -1,15 +1,16 @@ -import 'package:soundbox/models/album_model.dart'; -import 'package:soundbox/models/song_download_model.dart'; -import 'package:soundbox/models/song_image_model.dart'; +import 'package:soundbox/core/models/album_model.dart'; +import 'package:soundbox/core/models/song_download_model.dart'; +import 'package:soundbox/core/models/song_image_model.dart'; class Song { String id; String name; String? type; + String? lyrics; Album album; String year; String? releaseDate; - String duration; + int duration; String label; String primaryArtists; String primaryArtistsId; @@ -37,6 +38,7 @@ class Song { required this.playCount, required this.language, required this.hasLyrics, + this.lyrics, required this.url, required this.images, required this.downloadUrls, @@ -50,10 +52,10 @@ class Song { factory Song.fromJson(Map json) { return Song( id: json["id"], - name: json["name"], + name: json["name"].toString().replaceAll("&", "&"), album: Album.fromJson(json["album"]), year: json["year"], - duration: json["duration"], + duration: int.parse(json["duration"]), label: json["label"], primaryArtists: json["primaryArtists"], primaryArtistsId: json["primaryArtistsId"], diff --git a/lib/core/providers/song_provider.dart b/lib/core/providers/song_provider.dart new file mode 100644 index 0000000..705b425 --- /dev/null +++ b/lib/core/providers/song_provider.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:soundbox/core/models/lyrics_model.dart'; +import 'package:soundbox/core/models/song_model.dart'; +import 'package:soundbox/services/saavn_api_service.dart'; + +class SongProvider extends ChangeNotifier { + final _queue = ConcatenatingAudioSource( + useLazyPreparation: true, + children: [], + ); + final List _queueMeta = []; + final AudioPlayer _player = AudioPlayer(); + int _currentSongIndex = 0; + bool _isPlaying = false; + + AudioPlayer get player => _player; + bool get isPlaying => _isPlaying; + Song? get currentSong => + _queueMeta.isEmpty ? null : _queueMeta[_currentSongIndex]; + List? get currentQueue => _queueMeta; + ConcatenatingAudioSource get queue => _queue; + int get currentSongIndex => _currentSongIndex; + + SongProvider() { + _player.playingStream.listen((state) { + _isPlaying = state; + }); + _player.currentIndexStream.listen((index) { + if (index != null) { + _currentSongIndex = index; + notifyListeners(); + } + }); + } + + Future handlePausePlay() async { + if (_player.playing) { + await _player.pause(); + } else { + await _player.play(); + } + notifyListeners(); + } + + Future setAudioSource() async { + await _player.setAudioSource(_queue, + initialIndex: 0, initialPosition: Duration.zero); + } + + Future setCurrentSong(Song song) async { + int c = _currentSongIndex; + if (_queue.length == 0) c = -1; + await _queue.insert( + c + 1, AudioSource.uri(Uri.parse(song.downloadUrls.last.link))); + + if (_queueMeta.isEmpty) await setAudioSource(); + _queueMeta.insert(c + 1, song); + notifyListeners(); + await Future.delayed(Durations.medium1); + playNext(); + } + + Future addToQueue(Song song) async { + await _queue.add(AudioSource.uri(Uri.parse(song.downloadUrls.last.link))); + if (_queueMeta.isEmpty) await setAudioSource(); + _queueMeta.add(song); + notifyListeners(); + } + + Future seekToPoint(int seconds) async { + Duration currentDuration = _player.position; + await _player.seek(Duration(seconds: seconds + currentDuration.inSeconds)); + notifyListeners(); + } + + Future playPrevious() async { + Duration currentDuration = _player.position; + if (currentDuration.inSeconds < 2) { + await _player.seekToPrevious(); + notifyListeners(); + } else { + seekToPoint(-currentDuration.inSeconds); + } + } + + Future playNext() async { + await _player.seekToNext(); + if (!_player.playing) await _player.play(); + notifyListeners(); + } + + Stream positionStream() { + return _player.createPositionStream( + maxPeriod: const Duration(milliseconds: 20), + minPeriod: const Duration(milliseconds: 20), + ); + } + + Future getLyrics() async { + Song song = _queueMeta[_currentSongIndex]; + if (song.lyrics == null) { + if (song.hasLyrics == false) { + _queueMeta[_currentSongIndex].lyrics = 'No Lyrics Found'; + } else { + Lyrics? lyrics = await SaavnApiService().getLyrics(song.id); + if (lyrics == null) { + _queueMeta[_currentSongIndex].lyrics = 'No Lyrics Found'; + } else { + _queueMeta[_currentSongIndex].lyrics = lyrics.lyrics; + } + } + notifyListeners(); + } + } +} diff --git a/lib/theme_provider.dart b/lib/core/providers/theme_provider.dart similarity index 64% rename from lib/theme_provider.dart rename to lib/core/providers/theme_provider.dart index 357f368..388f106 100644 --- a/lib/theme_provider.dart +++ b/lib/core/providers/theme_provider.dart @@ -15,7 +15,14 @@ class ThemeProvider extends ChangeNotifier { } ThemeData themeData(Brightness brightness) { - return ThemeData( + ThemeData theme = ThemeData( + sliderTheme: SliderThemeData( + overlayShape: SliderComponentShape.noOverlay, + trackHeight: 40, + inactiveTrackColor: Colors.black, + trackShape: const RectangularSliderTrackShape(), + thumbShape: SliderComponentShape.noThumb, + ), brightness: brightness, fontFamily: GoogleFonts.poppins().fontFamily, textTheme: TextTheme( @@ -28,6 +35,9 @@ ThemeData themeData(Brightness brightness) { fontWeight: FontWeight.w700, ), ), - ); + theme = theme.copyWith( + sliderTheme: theme.sliderTheme + .copyWith(inactiveTrackColor: theme.colorScheme.background)); + return theme; } diff --git a/lib/home/home_page.dart b/lib/home/home_page.dart deleted file mode 100644 index c7c122e..0000000 --- a/lib/home/home_page.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soundbox/home/widgets/home_app_bar.dart'; -import 'package:soundbox/home/widgets/home_search_bar.dart'; - -class HomePage extends StatefulWidget { - const HomePage({super.key}); - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - Widget drawer = const Drawer(); - - void setDrawer(Widget newDrawer) { - setState(() { - drawer = newDrawer; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - drawer: drawer, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - children: [ - const HomeAppBar(), - const SizedBox( - height: 10, - ), - HomeSearchBar(setDrawer: setDrawer), - const SizedBox( - height: 10, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index e44b136..8b8d815 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:soundbox/home/home_page.dart'; -import 'package:soundbox/theme_provider.dart'; +import 'package:soundbox/pages/home/home_page.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; +import 'package:soundbox/core/providers/theme_provider.dart'; void main(List args) { + WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp()); } @@ -13,6 +15,7 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider(providers: [ ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider(create: (_) => SongProvider()), ], child: const Root()); } } diff --git a/lib/home/widgets/home_app_bar.dart b/lib/pages/home/home_app_bar.dart similarity index 93% rename from lib/home/widgets/home_app_bar.dart rename to lib/pages/home/home_app_bar.dart index ac1dfc3..03aa711 100644 --- a/lib/home/widgets/home_app_bar.dart +++ b/lib/pages/home/home_app_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:soundbox/theme_provider.dart'; +import 'package:soundbox/core/providers/theme_provider.dart'; class HomeAppBar extends StatelessWidget { const HomeAppBar({ diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart new file mode 100644 index 0000000..8ee0291 --- /dev/null +++ b/lib/pages/home/home_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:soundbox/core/extensions/with_gap_y.dart'; +import 'package:soundbox/pages/home/home_app_bar.dart'; +import 'package:soundbox/pages/home/home_queue_list.dart'; +import 'package:soundbox/pages/search/search_bar.dart'; +import 'package:soundbox/widgets/song_control_bar.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + Widget drawer = const Drawer(); + + void setDrawer(Widget newDrawer) { + setState(() { + drawer = newDrawer; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: drawer, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(10), + child: Stack( + children: [ + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HomeAppBar(), + SongSearchBar(setDrawer: setDrawer), + const HomeQueueList() + ].withGapY(height: 10), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: SongControleBar(setDrawer: setDrawer)) + ], + ), + ), + ), + ); + } +} + + +/* +Self host the API +Cache recently searched songs' results +Create artist page - visible by clicking on their name under a song or maybe even by searching(?) +Add functionality to add to favourites +Add functionality to create playlist +Add functionality to play the song +Add functionality to show the lyrics +Add functionality to generate playlists based on interest using ML + */ \ No newline at end of file diff --git a/lib/pages/home/home_queue_list.dart b/lib/pages/home/home_queue_list.dart new file mode 100644 index 0000000..c69659f --- /dev/null +++ b/lib/pages/home/home_queue_list.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/pages/search/search_result_tile.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class HomeQueueList extends StatelessWidget { + const HomeQueueList({ + super.key, + }); + + @override + Widget build(BuildContext context) { + SongProvider songProvider = Provider.of(context); + if (songProvider.currentQueue?.isEmpty ?? true) { + return const SizedBox(); + } + return ListView.builder( + shrinkWrap: true, + itemCount: songProvider.currentQueue?.length ?? 0, + itemBuilder: (context, index) => _QueueChild(index: index)); + } +} + +class _QueueChild extends StatelessWidget { + const _QueueChild({required this.index}); + final int index; + + @override + Widget build(BuildContext context) { + SongProvider songProvider = Provider.of(context); + if (index == songProvider.currentSongIndex) { + SearchResultTile( + clickable: false, song: songProvider.currentQueue!.elementAt(index)); + } + return Container( + color: Theme.of(context).colorScheme.surfaceVariant, + child: SearchResultTile( + clickable: false, song: songProvider.currentQueue!.elementAt(index)), + ); + } +} diff --git a/lib/pages/playing/playing_page.dart b/lib/pages/playing/playing_page.dart new file mode 100644 index 0000000..9b82cc7 --- /dev/null +++ b/lib/pages/playing/playing_page.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/core/extensions/with_gap_y.dart'; +import 'package:soundbox/pages/playing/playing_progress_bar.dart'; +import 'package:soundbox/pages/playing/playing_song_controls.dart'; +import 'package:soundbox/pages/playing/playing_song_lyrics.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class PlayingPage extends StatelessWidget { + const PlayingPage({super.key}); + + @override + Widget build(BuildContext context) { + SongProvider songProvider = Provider.of(context); + if (songProvider.currentSong == null) { + return const Center( + child: Text("No Song in Queue"), + ); + } + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const BackButton(), + Text( + "Now Playing", + style: Theme.of(context).textTheme.titleMedium, + ), + IconButton( + onPressed: () {}, icon: const Icon(Icons.more_vert)) + ], + ), + Center( + child: Padding( + padding: const EdgeInsets.all(30), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + songProvider.currentSong!.images.last.link, + ), + ), + ), + ), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + songProvider.currentSong!.name, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text(songProvider.currentSong!.primaryArtists), + ], + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.star_outline_rounded, + size: 30, + )) + ], + ), + const PlayingProgressBar(), + const PlayingSongControls(), + const PlayingSongLyrics() + ].withGapY(height: 10), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/playing/playing_progress_bar.dart b/lib/pages/playing/playing_progress_bar.dart new file mode 100644 index 0000000..ce0fffd --- /dev/null +++ b/lib/pages/playing/playing_progress_bar.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class PlayingProgressBar extends StatefulWidget { + const PlayingProgressBar({super.key}); + + @override + State createState() => _PlayingProgressBarState(); +} + +class _PlayingProgressBarState extends State { + double? dragPosition; + String secondsToString(int seconds) { + return "${(seconds / 60).floor()}:${(seconds % 60).toString().length == 1 ? '0' : ''}${seconds % 60}"; + } + + @override + Widget build(BuildContext context) { + SongProvider songProvider = Provider.of(context); + if (songProvider.currentSong == null) return const SizedBox(); + return StreamBuilder( + stream: songProvider.player.positionStream, + builder: (c, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox(); + } + int seconds = snapshot.data!.inSeconds; + return Stack( + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.onBackground)), + child: Slider( + activeColor: Theme.of(context) + .colorScheme + .secondary + .withOpacity(0.5), + value: dragPosition ?? + (seconds / songProvider.currentSong!.duration), + onChangeEnd: (v) { + songProvider.seekToPoint( + (songProvider.currentSong!.duration * v).floor() - + seconds); + dragPosition = null; + }, + onChanged: (v) { + setState(() { + dragPosition = v; + }); + }), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + secondsToString(seconds), + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground, + fontWeight: FontWeight.w900), + ), + Text( + secondsToString(songProvider.currentSong!.duration), + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground, + fontWeight: FontWeight.w900), + ), + ], + ), + ), + ], + ); + }); + } +} diff --git a/lib/pages/playing/playing_song_controls.dart b/lib/pages/playing/playing_song_controls.dart new file mode 100644 index 0000000..87f9cad --- /dev/null +++ b/lib/pages/playing/playing_song_controls.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class PlayingSongControls extends StatelessWidget { + const PlayingSongControls({super.key}); + + @override + Widget build(BuildContext context) { + final SongProvider songProvider = Provider.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: () { + songProvider.seekToPoint(-15); + }, + icon: const Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon( + Icons.keyboard_double_arrow_left, + ), + Text("15"), + ], + )), + IconButton( + onPressed: () { + songProvider.playPrevious(); + }, + icon: const Icon( + Icons.skip_previous_rounded, + size: 40, + )), + IconButton( + onPressed: songProvider.handlePausePlay, + icon: Icon( + songProvider.isPlaying + ? Icons.pause_circle_filled_rounded + : Icons.play_circle_fill_rounded, + size: 50, + )), + IconButton( + onPressed: () { + songProvider.playNext(); + }, + icon: const Icon( + Icons.skip_next_rounded, + size: 40, + )), + IconButton( + onPressed: () { + songProvider.seekToPoint(15); + }, + icon: const Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon( + Icons.keyboard_double_arrow_right, + ), + Text("15"), + ], + )), + ], + ); + } +} diff --git a/lib/pages/playing/playing_song_lyrics.dart b/lib/pages/playing/playing_song_lyrics.dart new file mode 100644 index 0000000..2fde182 --- /dev/null +++ b/lib/pages/playing/playing_song_lyrics.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class PlayingSongLyrics extends StatelessWidget { + const PlayingSongLyrics({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final SongProvider songProvider = Provider.of(context); + return SingleChildScrollView( + child: Column( + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surfaceVariant), + padding: const EdgeInsets.all(10), + child: Center( + child: FutureBuilder( + future: songProvider.getLyrics(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + return Text( + songProvider.currentSong!.lyrics!, + style: Theme.of(context).textTheme.headlineMedium, + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/home/widgets/home_search_bar.dart b/lib/pages/search/search_bar.dart similarity index 77% rename from lib/home/widgets/home_search_bar.dart rename to lib/pages/search/search_bar.dart index 9e3ed22..f47df6d 100644 --- a/lib/home/widgets/home_search_bar.dart +++ b/lib/pages/search/search_bar.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:soundbox/search/search_results.dart'; +import 'package:soundbox/pages/search/search_result_drawer.dart'; -class HomeSearchBar extends StatelessWidget { - const HomeSearchBar({super.key, required this.setDrawer}); +class SongSearchBar extends StatelessWidget { + const SongSearchBar({super.key, required this.setDrawer}); final Function(Widget) setDrawer; @@ -20,7 +20,7 @@ class HomeSearchBar extends StatelessWidget { if (controller.text.isEmpty) { return; } - setDrawer(SearchResultsDrawer(query: controller.text)); + setDrawer(SearchResultDrawer(query: controller.text)); Scaffold.of(context).openDrawer(); }, icon: const Icon(Icons.search)), diff --git a/lib/search/search_results.dart b/lib/pages/search/search_result_drawer.dart similarity index 61% rename from lib/search/search_results.dart rename to lib/pages/search/search_result_drawer.dart index db745bb..421cbf4 100644 --- a/lib/search/search_results.dart +++ b/lib/pages/search/search_result_drawer.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:soundbox/models/song_model.dart'; +import 'package:soundbox/core/models/song_model.dart'; import 'package:soundbox/services/saavn_api_service.dart'; -import 'package:soundbox/search/search_result_tile.dart'; +import 'package:soundbox/pages/search/search_result_tile.dart'; -class SearchResultsDrawer extends StatelessWidget { - const SearchResultsDrawer({super.key, required this.query}); +class SearchResultDrawer extends StatelessWidget { + const SearchResultDrawer({super.key, required this.query}); final String query; @override Widget build(BuildContext context) { return Drawer( + width: double.infinity, child: Padding( padding: const EdgeInsets.all(8), child: Column( @@ -18,17 +19,20 @@ class SearchResultsDrawer extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Showing Results for', - ), - Text( - '"$query"', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Showing Results for', + ), + Text( + '"$query"', + style: Theme.of(context).textTheme.headlineMedium, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), const CloseButton() ], @@ -36,20 +40,20 @@ class SearchResultsDrawer extends StatelessWidget { const SizedBox( height: 10, ), - Expanded(child: SearchResults(query: query)), + Expanded(child: _SearchResults(query: query)), ], ), )); } } -class SearchResults extends StatelessWidget { - const SearchResults({super.key, required this.query}); +class _SearchResults extends StatelessWidget { + const _SearchResults({required this.query}); final String query; @override Widget build(BuildContext context) { return FutureBuilder( - future: SaavnApiService().searchForSong(query, limit: 10), + future: SaavnApiService().searchForSong(query, limit: 25), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasData) { diff --git a/lib/pages/search/search_result_tile.dart b/lib/pages/search/search_result_tile.dart new file mode 100644 index 0000000..b070b10 --- /dev/null +++ b/lib/pages/search/search_result_tile.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/core/models/song_model.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class PopUpMenuOption { + final Icon icon; + final String label; + final Function(SongProvider, Song) onTap; + + PopUpMenuOption( + {required this.icon, required this.label, required this.onTap}); +} + +List options = [ + PopUpMenuOption( + icon: const Icon(Icons.add), + label: "Add to Queue", + onTap: (songProvider, song) { + songProvider.addToQueue(song); + }), + PopUpMenuOption( + icon: const Icon(Icons.play_arrow_rounded), + label: "Play Song", + onTap: (songProvider, song) { + songProvider.setCurrentSong(song); + }), + /* PopUpMenuOption( + icon: const Icon(Icons.person), + label: "View Artist", + onTap: (songProvider, song) {}), + PopUpMenuOption( + icon: const Icon(Icons.star_rounded), + label: "Add to Favourites", + onTap: (songProvider, song) {}), */ +]; + +class SearchResultTile extends StatelessWidget { + const SearchResultTile( + {super.key, required this.song, this.clickable = true}); + final Song song; + final bool clickable; + + row() { + if (song.explicitContent == 1) { + return Row( + children: [ + const Icon( + Icons.explicit, + size: 15, + ), + const SizedBox( + width: 5, + ), + Expanded( + child: + Text(song.primaryArtists, overflow: TextOverflow.ellipsis)), + ], + ); + } + return Text(song.primaryArtists, overflow: TextOverflow.ellipsis); + } + + @override + Widget build(BuildContext context) { + SongProvider songProvider = Provider.of(context); + return ListTile( + onTap: () async { + if (!clickable) return; + Navigator.pop(context); + songProvider.setCurrentSong(song); + }, + dense: true, + title: Text( + song.name, + style: const TextStyle(fontSize: 15), + maxLines: 1, + ), + subtitle: row(), + leading: Image.network(song.images.first.link), + contentPadding: const EdgeInsets.symmetric(horizontal: 5), + trailing: PopupMenuButton(onSelected: (value) { + options + .where((element) => element == value) + .first + .onTap(songProvider, song); + }, itemBuilder: (context) { + return options + .map((e) => PopupMenuItem( + value: e, + child: Wrap( + children: [ + e.icon, + const SizedBox( + width: 10, + ), + Text(e.label) + ], + ), + )) + .toList(); + })); + } +} diff --git a/lib/search/search_result_tile.dart b/lib/search/search_result_tile.dart deleted file mode 100644 index d49503b..0000000 --- a/lib/search/search_result_tile.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soundbox/models/song_model.dart'; - -class SearchResultTile extends StatelessWidget { - const SearchResultTile({super.key, required this.song}); - final Song song; - - row() { - if (song.explicitContent == 1) { - return Row( - children: [ - const Icon( - Icons.explicit, - size: 15, - ), - const SizedBox( - width: 5, - ), - Expanded( - child: Text(song.primaryArtists, - overflow: TextOverflow.ellipsis)), - ], - ); - } - return Text(song.primaryArtists, overflow: TextOverflow.ellipsis); - } - - @override - Widget build(BuildContext context) { - return ListTile( - dense: true, - title: Text( - song.name, - style: const TextStyle(fontSize: 15), - maxLines: 1, - ), - subtitle: row(), - leading: Image.network(song.images.first.link, fit: BoxFit.cover), - contentPadding: const EdgeInsets.symmetric(horizontal: 5), - trailing: IconButton( - icon: const Icon(Icons.more_vert_sharp), - onPressed: () {}, - padding: const EdgeInsets.all(0), - constraints: const BoxConstraints()), - ); - } -} \ No newline at end of file diff --git a/lib/services/saavn_api_service.dart b/lib/services/saavn_api_service.dart index 0567110..6727414 100644 --- a/lib/services/saavn_api_service.dart +++ b/lib/services/saavn_api_service.dart @@ -1,7 +1,7 @@ import 'dart:convert'; - import 'package:http/http.dart' as http; -import 'package:soundbox/models/song_model.dart'; +import 'package:soundbox/core/models/lyrics_model.dart'; +import 'package:soundbox/core/models/song_model.dart'; class SaavnApiService { Future?> searchForSong(String query, {int limit = 5}) async { @@ -27,4 +27,20 @@ class SaavnApiService { } return songs; } -} \ No newline at end of file + + Future getLyrics(String songId) async { + Uri uri = Uri( + scheme: "https", + host: "saavn.me", + path: "lyrics", + queryParameters: { + "id": songId, + }); + + var response = await http.get(uri); + if (response.statusCode != 200) { + return null; + } + return Lyrics.fromJson(jsonDecode(response.body)); + } +} diff --git a/lib/widgets/song_control_bar.dart b/lib/widgets/song_control_bar.dart new file mode 100644 index 0000000..8bcf5a3 --- /dev/null +++ b/lib/widgets/song_control_bar.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:soundbox/pages/playing/playing_page.dart'; +import 'package:soundbox/core/providers/song_provider.dart'; + +class SongControleBar extends StatelessWidget { + const SongControleBar({super.key, required this.setDrawer}); + final Function(Widget) setDrawer; + + @override + Widget build(BuildContext context) { + SongProvider songProvider = Provider.of(context); + if (songProvider.currentSong == null) { + return Container(); + } + return Stack( + alignment: Alignment.topLeft, + children: [ + _ProgressBar(songProvider: songProvider), + Container( + margin: const EdgeInsets.all(5), + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)), + color: Theme.of(context).colorScheme.surfaceVariant), + child: ListTile( + onTap: () { + setDrawer(const PlayingPage()); + Scaffold.of(context).openDrawer(); + }, + minVerticalPadding: 0, + dense: true, + title: Text( + songProvider.currentSong!.name, + style: const TextStyle(fontSize: 15), + maxLines: 1, + ), + subtitle: Text(songProvider.currentSong?.primaryArtists ?? '', + overflow: TextOverflow.ellipsis), + leading: songProvider.currentSong?.images.first.link != null + ? ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + songProvider.currentSong?.images.first.link ?? '', + fit: BoxFit.cover), + ) + : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 5), + trailing: Wrap( + children: [ + IconButton( + icon: const Icon(Icons.skip_previous), + onPressed: () { + songProvider.playPrevious(); + }), + IconButton( + icon: Icon(songProvider.isPlaying + ? Icons.pause + : Icons.play_arrow), + onPressed: songProvider.handlePausePlay), + IconButton( + icon: const Icon(Icons.skip_next), + onPressed: () { + songProvider.playNext(); + }), + ], + ), + )), + ], + ); + } +} + +class _ProgressBar extends StatelessWidget { + const _ProgressBar({ + required this.songProvider, + }); + + final SongProvider songProvider; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: StreamBuilder( + stream: songProvider.positionStream(), + builder: (context, snapshot) { + int millisecondsPassed = snapshot.data?.inMilliseconds ?? 0; + int songDuration = (songProvider.currentSong?.duration ?? 1) * 1000; + int progress = + ((millisecondsPassed * 100000 / songDuration)).ceil(); + return Row( + children: [ + Expanded( + flex: progress, + child: Container( + height: 5, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(5)), + color: Theme.of(context).colorScheme.onBackground), + child: const SizedBox(), + ), + ), + Expanded( + flex: 100000 - progress, + child: Container( + height: 5, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(5)), + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.5)), + child: const SizedBox(), + ), + ), + ], + ); + }), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..1830e5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..e9abb91 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/pubspec.lock b/pubspec.lock index 55bf53e..447a8be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f" + url: "https://pub.dev" + source: hosted + version: "0.1.18" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: c05c6147124cd63e725e861335a8b4d57300b80e6e92cea7c145c739223bbaef + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: b00e1a0e11365d88576320ec2d8c192bc21f1afb6c0e5995d1c57ae63156acb5 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "3034e99a6df8d101da0f5082dcca0a2a99db62ab1d4ddb3277bed3f6f81afe08" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "60787e73fefc4d2e0b9c02c69885402177e818e4e27ef087074cf27c02246c9e" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "365c547f1bb9e77d94dd1687903a668d8f7ac3409e48e6e6a3668a1ac2982adb" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "22cd0173e54d92bd9b2c80b1204eb1eb159ece87475ab58c9788a70ec43c2a62" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "9536812c9103563644ada2ef45ae523806b0745f7a78e89d1b5fb1951de90e1a" + url: "https://pub.dev" + source: hosted + version: "3.1.0" boolean_selector: dependency: transitive description: @@ -73,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" flutter: dependency: "direct main" description: flutter @@ -91,6 +163,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" google_fonts: dependency: "direct main" description: @@ -115,6 +192,46 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: b607cd1a43bac03d85c3aaee00448ff4a589ef2a77104e3d409889ff079bf823 + url: "https://pub.dev" + source: hosted + version: "0.9.36" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: c3dee0014248c97c91fe6299edb73dc4d6c6930a2f4f713579cd692d9e47f4a1 + url: "https://pub.dev" + source: hosted + version: "4.2.2" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "134356b0fe3d898293102b33b5fd618831ffdc72bb7a1b726140abdf22772b70" + url: "https://pub.dev" + source: hosted + version: "0.4.9" + just_audio_windows: + dependency: "direct main" + description: + name: just_audio_windows + sha256: "7b8801f3987e98a2002cd23b5600b2daf162248ff1413266fb44c84448c1c0d3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" lints: dependency: transitive description: @@ -235,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" sky_engine: dependency: transitive description: flutter @@ -248,6 +373,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -272,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -296,6 +437,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" + url: "https://pub.dev" + source: hosted + version: "4.2.2" vector_math: dependency: transitive description: @@ -330,4 +479,4 @@ packages: version: "1.0.3" sdks: dart: ">=3.2.3 <4.0.0" - flutter: ">=3.7.0" + flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index cdac7b3..2659ffb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,12 +32,15 @@ dependencies: sdk: flutter provider: google_fonts: + just_audio: ^0.9.36 + just_audio_windows: ^0.2.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 http: ^1.1.2 + audioplayers: ^5.2.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..af15f08 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + JustAudioWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("JustAudioWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..a87969d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + just_audio_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST