diff --git a/lib/components/CategoryCard.dart b/lib/components/CategoryCard.dart index 2f60e6b5..c9916deb 100644 --- a/lib/components/CategoryCard.dart +++ b/lib/components/CategoryCard.dart @@ -7,7 +7,7 @@ import 'package:spotube/provider/SpotifyDI.dart'; class CategoryCard extends StatefulWidget { final Category category; - CategoryCard(this.category); + const CategoryCard(this.category, {Key? key}) : super(key: key); @override _CategoryCardState createState() => _CategoryCardState(); @@ -16,56 +16,57 @@ class CategoryCard extends StatefulWidget { class _CategoryCardState extends State { @override Widget build(BuildContext context) { - return Container( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.category.name ?? "Unknown", - style: Theme.of(context).textTheme.headline5, - ), - TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return PlaylistGenreView(widget.category.id!); - }, - ), - ); - }, - child: Text("See all"), - ) - ], - ), + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.category.name ?? "Unknown", + style: Theme.of(context).textTheme.headline5, + ), + TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return PlaylistGenreView( + widget.category.id!, + widget.category.name!, + ); + }, + ), + ); + }, + child: const Text("See all"), + ) + ], ), - Consumer( - builder: (context, data, child) => - FutureBuilder>( - future: data.spotifyApi.playlists - .getByCategoryId(widget.category.id!) - .getPage(4, 0), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center(child: Text("Error occurred")); - } - if (!snapshot.hasData) { - return Center(child: Text("Loading..")); - } - return Wrap( - spacing: 20, - children: snapshot.data!.items! - .map((playlist) => PlaylistCard(playlist)) - .toList(), - ); - }), - ) - ], - ), + ), + Consumer( + builder: (context, data, child) => + FutureBuilder>( + future: data.spotifyApi.playlists + .getByCategoryId(widget.category.id!) + .getPage(4, 0), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center(child: Text("Error occurred")); + } + if (!snapshot.hasData) { + return const Center(child: Text("Loading..")); + } + return Wrap( + spacing: 20, + children: snapshot.data!.items! + .map((playlist) => PlaylistCard(playlist)) + .toList(), + ); + }), + ) + ], ); } } diff --git a/lib/components/Home.dart b/lib/components/Home.dart index 88aabf96..bd2b2ea2 100644 --- a/lib/components/Home.dart +++ b/lib/components/Home.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' hide Page; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotify/spotify.dart'; @@ -10,11 +11,16 @@ import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/SpotifyDI.dart'; class Home extends StatefulWidget { + const Home({Key? key}) : super(key: key); + @override _HomeState createState() => _HomeState(); } class _HomeState extends State { + final PagingController _pagingController = + PagingController(firstPageKey: 0); + @override void initState() { super.initState(); @@ -39,12 +45,35 @@ class _HomeState extends State { ); } } + _pagingController.addPageRequestListener((pageKey) async { + try { + SpotifyDI data = context.read(); + Page categories = await data.spotifyApi.categories + .list(country: "US") + .getPage(15, pageKey); + + if (categories.isLast && categories.items != null) { + _pagingController.appendLastPage(categories.items!.toList()); + } else if (categories.items != null) { + _pagingController.appendPage( + categories.items!.toList(), categories.nextOffset); + } + } catch (e) { + _pagingController.error = e; + } + }); } catch (e) { print("[login state error]: $e"); } }); } + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { Auth authProvider = Provider.of(context); @@ -53,101 +82,84 @@ class _HomeState extends State { } return Scaffold( - body: Container( - child: Column( - children: [ - // Side Tab Bar - Expanded( - child: Row( - children: [ - Container( - color: Colors.grey.shade100, - constraints: const BoxConstraints(maxWidth: 230), - child: Material( - type: MaterialType.transparency, - child: Column( - children: [ - Flexible( - flex: 1, - // TabButtons - child: Column( - children: [ - ListTile( - title: Text("Spotube", - style: Theme.of(context) - .textTheme - .headline4), - leading: - const Icon(Icons.miscellaneous_services), - ), - const SizedBox(height: 20), - ...sidebarTileList - .map( - (sidebarTile) => ListTile( - title: Text(sidebarTile.title), - leading: Icon(sidebarTile.icon), - onTap: () {}, - ), - ) - .toList(), - ], - ), + body: Column( + children: [ + // Side Tab Bar + Expanded( + child: Row( + children: [ + Container( + color: Colors.grey.shade100, + constraints: const BoxConstraints(maxWidth: 230), + child: Material( + type: MaterialType.transparency, + child: Column( + children: [ + Flexible( + flex: 1, + // TabButtons + child: Column( + children: [ + ListTile( + title: Text("Spotube", + style: + Theme.of(context).textTheme.headline4), + leading: + const Icon(Icons.miscellaneous_services), + ), + const SizedBox(height: 20), + ...sidebarTileList + .map( + (sidebarTile) => ListTile( + title: Text(sidebarTile.title), + leading: Icon(sidebarTile.icon), + onTap: () {}, + ), + ) + .toList(), + ], ), - // user name & settings - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "User's name", - style: TextStyle(fontWeight: FontWeight.bold), - ), - IconButton( - icon: const Icon(Icons.settings_outlined), - onPressed: () {}), - ], - ), - ) - ], - ), + ), + // user name & settings + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "User's name", + style: TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () {}), + ], + ), + ) + ], ), ), - // contents of the spotify - Consumer(builder: (_, data, __) { - return FutureBuilder>( - future: data.spotifyApi.categories - .list(country: "US") - .getPage(10, 0), - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Center(child: Text("Error occured")); - } - if (!snapshot.hasData) { - return const Center(child: Text("Loading")); - } - List categories = - snapshot.data!.items!.toList(); - return Expanded( - child: Scrollbar( - isAlwaysShown: true, - child: ListView.builder( - itemCount: categories.length, - itemBuilder: (context, index) { - return CategoryCard(categories[index]); - }, - ), - ), - ); - }); - }), - ], - ), + ), + // contents of the spotify + Consumer(builder: (_, data, __) { + return Expanded( + child: Scrollbar( + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return CategoryCard(item); + }, + )), + ), + ); + }), + ], ), - // player itself - const player.Player() - ], - ), + ), + // player itself + const player.Player() + ], ), ); } diff --git a/lib/components/Player.dart b/lib/components/Player.dart index 92668f6a..b4161e1e 100644 --- a/lib/components/Player.dart +++ b/lib/components/Player.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:spotube/components/PlayerControls.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; @@ -18,8 +20,7 @@ class _PlayerState extends State { late MPVPlayer player; bool _isPlaying = false; - String? _mediaTitle; - String? _mediaArtists; + bool _shuffled = false; double _duration = 0; String? _currentPlaylistId; @@ -45,7 +46,9 @@ class _PlayerState extends State { _volume = volume / 100; }); } catch (e) { - print("[PLAYER]: $e"); + if (kDebugMode) { + print("[PLAYER]: $e"); + } } })(); @@ -65,20 +68,27 @@ class _PlayerState extends State { player.on(MPVEvents.status, null, (ev, _) async { Map data = ev.eventData as Map; Playback playback = context.read(); - print("[DATA]: $data"); if (data["property"] == "media-title" && data["value"] != null) { - var props = (data["value"] as String).split("-"); - setState(() { - _isPlaying = true; - _mediaTitle = props.last.replaceAll( - RegExp( - "(official|video|lyric|[(){}\\[\\]\\|])", - caseSensitive: false, - ), - "", - ); - _mediaArtists = props.first; - }); + var containsYtdl = (data["value"] as String).contains("ytsearch:"); + if (containsYtdl) { + var props = (data["value"] as String).split("-"); + var mediaTitle = props.last.trim(); + var mediaArtists = props.first.split("ytsearch:").last.trim(); + setState(() { + _isPlaying = true; + }); + + var matchedTracks = playback.currentPlaylist?.tracks.where( + (track) { + return track.name == mediaTitle && + artistsToString(track.artists ?? []) == mediaArtists; + }, + ) ?? + []; + if (matchedTracks.isNotEmpty) { + playback.setCurrentTrack = matchedTracks.first; + } + } } if (data["property"] == "duration" && data["value"] != null) { setState(() { @@ -90,30 +100,6 @@ class _PlayerState extends State { super.initState(); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - Playback playback = context.read(); - - String? prevTrackName = playback.currentTrack?.name; - String prevTrackArtists = - artistsToString(playback.currentTrack?.artists ?? []); - - if (playback.currentPlaylist != null && - playback.currentPlaylist!.tracks.isNotEmpty && - prevTrackName != _mediaTitle && - prevTrackArtists != _mediaArtists) { - var tracks = playback.currentPlaylist?.tracks.where((track) { - return _mediaTitle == track.name! && - artistsToString(track.artists ?? []) == _mediaTitle; - }) ?? - []; - if (tracks.isNotEmpty) { - playback.setCurrentTrack = tracks.first; - } - } - } - @override void dispose() { player.removeAllByEvent(MPVEvents.paused); @@ -134,7 +120,6 @@ class _PlayerState extends State { File file = File(playlistPath); var newPlaylist = playlistToStr(playlist); - print("😃PLAYING PLAYLIST😃"); if (!await file.exists()) { await file.create(); } @@ -144,6 +129,7 @@ class _PlayerState extends State { await player.loadPlaylist(playlistPath); setState(() { _currentPlaylistId = playlist.id; + _shuffled = false; }); } } @@ -162,21 +148,30 @@ class _PlayerState extends State { playPlaylist(playback.currentPlaylist!); } + String? albumArt = playback.currentTrack?.album?.images?.last.url; + return Material( type: MaterialType.transparency, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + if (albumArt != null) + CachedNetworkImage( + imageUrl: albumArt, + maxHeightDiskCache: 50, + maxWidthDiskCache: 50, + ), // title of the currently playing track Flexible( flex: 1, child: Column( children: [ Text( - _mediaTitle ?? "Not playing", + playback.currentTrack?.name ?? "Not playing", style: const TextStyle(fontWeight: FontWeight.bold), ), - Text(_mediaArtists ?? "") + Text( + artistsToString(playback.currentTrack?.artists ?? [])) ], ), ), @@ -187,13 +182,28 @@ class _PlayerState extends State { player: player, isPlaying: _isPlaying, duration: _duration, + shuffled: _shuffled, + onShuffle: () { + if (!_shuffled) { + player.shuffle().then( + (value) => setState(() { + _shuffled = true; + }), + ); + } else { + player.unshuffle().then( + (value) => setState(() { + _shuffled = false; + }), + ); + } + }, onStop: () { setState(() { _isPlaying = false; _currentPlaylistId = null; - _mediaArtists = null; - _mediaTitle = null; _duration = 0; + _shuffled = false; }); playback.reset(); }, diff --git a/lib/components/PlayerControls.dart b/lib/components/PlayerControls.dart index 70c22620..46b4ba04 100644 --- a/lib/components/PlayerControls.dart +++ b/lib/components/PlayerControls.dart @@ -5,11 +5,15 @@ class PlayerControls extends StatefulWidget { final MPVPlayer player; final bool isPlaying; final double duration; + final bool shuffled; final Function? onStop; + final Function? onShuffle; const PlayerControls({ required this.player, required this.isPlaying, required this.duration, + required this.shuffled, + this.onShuffle, this.onStop, Key? key, }) : super(key: key); @@ -86,8 +90,10 @@ class _PlayerControlsState extends State { children: [ IconButton( icon: const Icon(Icons.shuffle_rounded), - onPressed: () async { - await widget.player.shuffle(); + color: + widget.shuffled ? Theme.of(context).primaryColor : null, + onPressed: () { + widget.onShuffle?.call(); }), IconButton( icon: const Icon(Icons.skip_previous_rounded), diff --git a/lib/components/PlaylistGenreView.dart b/lib/components/PlaylistGenreView.dart index 211038f2..4714469c 100644 --- a/lib/components/PlaylistGenreView.dart +++ b/lib/components/PlaylistGenreView.dart @@ -5,8 +5,10 @@ import 'package:spotube/components/PlaylistCard.dart'; import 'package:spotube/provider/SpotifyDI.dart'; class PlaylistGenreView extends StatefulWidget { - String genre_id; - PlaylistGenreView(this.genre_id); + final String genreId; + final String genreName; + const PlaylistGenreView(this.genreId, this.genreName, {Key? key}) + : super(key: key); @override _PlaylistGenreViewState createState() => _PlaylistGenreViewState(); } @@ -15,53 +17,51 @@ class _PlaylistGenreViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Container( - child: Column( - children: [ - Row( - // mainAxisAlignment: MainAxisAlignment.center, - children: [ - BackButton(), - // genre name - Expanded( - child: Text( - "Genre Name", - style: Theme.of(context).textTheme.headline4, - textAlign: TextAlign.center, - ), - ), - ], - ), - Consumer( - builder: (context, data, child) => Expanded( - child: SingleChildScrollView( - child: FutureBuilder>( - future: data.spotifyApi.playlists - .getByCategoryId(widget.genre_id) - .all(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center(child: Text("Error occurred")); - } - if (!snapshot.hasData) { - return Center(child: Text("Loading..")); - } - return Wrap( - children: snapshot.data! - .map( - (playlist) => Padding( - padding: const EdgeInsets.all(8.0), - child: PlaylistCard(playlist), - ), - ) - .toList(), - ); - }), + body: Column( + children: [ + Row( + // mainAxisAlignment: MainAxisAlignment.center, + children: [ + const BackButton(), + // genre name + Expanded( + child: Text( + widget.genreName, + style: Theme.of(context).textTheme.headline4, + textAlign: TextAlign.center, ), ), - ) - ], - ), + ], + ), + Consumer( + builder: (context, data, child) => Expanded( + child: SingleChildScrollView( + child: FutureBuilder>( + future: data.spotifyApi.playlists + .getByCategoryId(widget.genreId) + .all(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center(child: Text("Error occurred")); + } + if (!snapshot.hasData) { + return const Center(child: Text("Loading..")); + } + return Wrap( + children: snapshot.data! + .map( + (playlist) => Padding( + padding: const EdgeInsets.all(8.0), + child: PlaylistCard(playlist), + ), + ) + .toList(), + ); + }), + ), + ), + ) + ], ), ); } diff --git a/lib/components/PlaylistView.dart b/lib/components/PlaylistView.dart index 2a0d1a22..dd91906c 100644 --- a/lib/components/PlaylistView.dart +++ b/lib/components/PlaylistView.dart @@ -6,8 +6,8 @@ import 'package:spotube/components/TrackButton.dart'; import 'package:spotube/provider/SpotifyDI.dart'; class PlaylistView extends StatefulWidget { - PlaylistSimple playlist; - PlaylistView(this.playlist); + final PlaylistSimple playlist; + const PlaylistView(this.playlist, {Key? key}) : super(key: key); @override _PlaylistViewState createState() => _PlaylistViewState(); } @@ -18,87 +18,99 @@ class _PlaylistViewState extends State { Playback playback = context.read(); return Consumer(builder: (_, data, __) { return Scaffold( - body: Container( - child: FutureBuilder>( - future: data.spotifyApi.playlists - .getTracksByPlaylistId(widget.playlist.id) - .all(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Center(child: const Text("Error occurred")); - } - if (!snapshot.hasData) { - return const Center(child: const Text("Loading..")); - } - List tracks = snapshot.data!.toList(); - return Column( - children: [ - Row( - children: [ - // nav back - const BackButton(), - // heart playlist - IconButton( - icon: const Icon(Icons.favorite_outline_rounded), - onPressed: () {}, - ), - // play playlist - IconButton( - icon: const Icon(Icons.play_arrow_rounded), - onPressed: () { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: widget.playlist.id!, - name: widget.playlist.name!, - thumbnail: widget.playlist.images![0].url!, - ); - }, - ), - ], - ), - Center( - child: Text(widget.playlist.name!, - style: Theme.of(context).textTheme.headline4), - ), - Expanded( - child: Scrollbar( - isAlwaysShown: true, - child: ListView.builder( - itemCount: tracks.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Column( - children: [ - TrackButton( - index: "#", - trackName: "Title", - artists: ["Artist"], - album: "Album", - playback_time: "Time"), - const Divider() - ], - ); - } - Track track = tracks[index - 1]; - return TrackButton( - index: (index - 1).toString(), - thumbnail_url: track.album?.images?.last.url ?? - "https://i.scdn.co/image/ab67616d00001e02b993cba8ff7d0a8e9ee18d46", - trackName: track.name!, - artists: - track.artists!.map((e) => e.name!).toList(), - album: track.album!.name!, - playback_time: - track.duration!.inMinutes.toString(), - onTap: () {}, - ); - }), + body: FutureBuilder>( + future: data.spotifyApi.playlists + .getTracksByPlaylistId(widget.playlist.id) + .all(), + builder: (context, snapshot) { + List tracks = snapshot.data?.toList() ?? []; + return Column( + children: [ + Row( + children: [ + // nav back + const BackButton(), + // heart playlist + IconButton( + icon: const Icon(Icons.favorite_outline_rounded), + onPressed: () {}, ), - ), - ], - ); - }), - ), + // play playlist + Consumer(builder: (context, playback, widget) { + var isPlaylistPlaying = playback.currentPlaylist?.id == + this.widget.playlist.id; + return IconButton( + icon: Icon( + isPlaylistPlaying + ? Icons.stop_rounded + : Icons.play_arrow_rounded, + ), + onPressed: snapshot.hasData + ? () { + if (!isPlaylistPlaying) { + playback.setCurrentPlaylist = + CurrentPlaylist( + tracks: tracks, + id: this.widget.playlist.id!, + name: this.widget.playlist.name!, + thumbnail: + this.widget.playlist.images![0].url!, + ); + } + } + : null, + ); + }), + ], + ), + Center( + child: Text(widget.playlist.name!, + style: Theme.of(context).textTheme.headline4), + ), + snapshot.hasError + ? const Center(child: Text("Error occurred")) + : !snapshot.hasData + ? const Center(child: Text("Loading..")) + : Expanded( + child: Scrollbar( + isAlwaysShown: true, + child: ListView.builder( + itemCount: tracks.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Column( + children: [ + TrackButton( + index: "#", + trackName: "Title", + artists: ["Artist"], + album: "Album", + playback_time: "Time"), + const Divider() + ], + ); + } + Track track = tracks[index - 1]; + return TrackButton( + index: (index - 1).toString(), + thumbnail_url: track + .album?.images?.last.url ?? + "https://i.scdn.co/image/ab67616d00001e02b993cba8ff7d0a8e9ee18d46", + trackName: track.name!, + artists: track.artists! + .map((e) => e.name!) + .toList(), + album: track.album!.name!, + playback_time: track.duration!.inMinutes + .toString(), + onTap: () {}, + ); + }), + ), + ), + ], + ); + }), ); }); } diff --git a/lib/components/TrackButton.dart b/lib/components/TrackButton.dart index 49f940ef..fc361e18 100644 --- a/lib/components/TrackButton.dart +++ b/lib/components/TrackButton.dart @@ -30,7 +30,7 @@ class _TrackButtonState extends State { child: InkWell( onTap: widget.onTap, child: Ink( - padding: EdgeInsets.all(10), + padding: const EdgeInsets.all(10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -38,16 +38,16 @@ class _TrackButtonState extends State { children: [ Text( widget.index, - style: TextStyle(fontSize: 20), + style: const TextStyle(fontSize: 20), ), - SizedBox(width: 15), + const SizedBox(width: 15), if (widget.thumbnail_url != null) CachedNetworkImage( imageUrl: widget.thumbnail_url!, maxHeightDiskCache: 50, maxWidthDiskCache: 50, ), - SizedBox(width: 15), + const SizedBox(width: 15), Container( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -55,7 +55,7 @@ class _TrackButtonState extends State { Text( widget.trackName, textAlign: TextAlign.justify, - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 17), ), Text(widget.artists.join(", ")) @@ -64,9 +64,9 @@ class _TrackButtonState extends State { ), ], ), - SizedBox(width: 15), + const SizedBox(width: 15), Text(widget.album), - SizedBox(width: 15), + const SizedBox(width: 15), Text(widget.playback_time) ], ), diff --git a/lib/main.dart b/lib/main.dart index 7a224bc2..192aae66 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,8 +29,8 @@ class MyApp extends StatelessWidget { theme: ThemeData( primaryColor: Colors.greenAccent[400], primarySwatch: Colors.green, - buttonTheme: ButtonThemeData( - buttonColor: Colors.greenAccent[400], + buttonTheme: const ButtonThemeData( + buttonColor: Colors.green, ), ), home: Home(), diff --git a/pubspec.lock b/pubspec.lock index c8dc9d2d..6c4b0a05 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.0" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" js: dependency: transitive description: @@ -406,6 +413,13 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.5" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b314d497..a2dda71f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: youtube_explode_dart: ^1.10.8 mpv_dart: path: ../mpv_dart + infinite_scroll_pagination: ^3.1.0 dev_dependencies: flutter_test: