diff --git a/lib/globals.dart b/lib/globals.dart new file mode 100644 index 0000000..4cf3bfb --- /dev/null +++ b/lib/globals.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +late String token; +TextStyle globalTextStyle = TextStyle(color: Colors.grey[350]); diff --git a/lib/main.dart b/lib/main.dart index 8bb12db..6bdb9da 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,22 +1,13 @@ -// ignore_for_file: prefer_const_literals_to_create_immutables, avoid_print - import 'dart:convert'; -import 'package:fip_parser_ui/queries.dart'; +import 'package:fip_parser_ui/globals.dart'; +import 'package:fip_parser_ui/providers/home.dart'; +import 'package:fip_parser_ui/providers/player.dart'; +import 'package:fip_parser_ui/screens/home.dart'; import 'package:flutter/material.dart'; -import 'package:graphql/client.dart'; -import 'package:retry/retry.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:http/http.dart' as http; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; +import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; import 'package:kplayer/kplayer.dart'; -const int queryTimeout = 8; -late String token; -const hours = 3; -const String radio = 'groove'; -PlayerController? player; - Future main() async { WidgetsFlutterBinding.ensureInitialized(); Player.boot(); @@ -30,294 +21,17 @@ class FipyApp extends StatelessWidget { @override Widget build(BuildContext context) { - // final radioList = [ - // 'fip', - // 'electro', - // 'groove', - // 'rock', - // 'jazz', - // 'pop', - // 'reggae', - // 'world', - // 'nouveautes', - // ]; - - // ####### - - return MaterialApp( - title: 'Fipy', - theme: ThemeData( - primarySwatch: Colors.blue, backgroundColor: Colors.grey[900]), - home: const HomePage(title: 'Fip moi ça'), - ); - } -} - -class HomePage extends StatefulWidget { - const HomePage({Key? key, required this.title}) : super(key: key); - - final String title; - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - body: SingleChildScrollView( - child: Column( - children: [ - Container( - color: Colors.grey[900], - height: 50, - child: Center( - child: Text( - 'Radio: ' + radio + '\nHistorique: ' + hours.toString() + 'h', - style: globalTextStyle, - ), - ), - ), - FutureBuilder>( - future: getTracks(), - builder: ( - BuildContext context, - AsyncSnapshot> snapshot, - ) { - print(snapshot.connectionState); - if (snapshot.connectionState == ConnectionState.waiting) { - return const CircularProgressIndicator(); - } else if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return const Text('Error'); - } else if (snapshot.hasData) { - return Table( - defaultVerticalAlignment: - TableCellVerticalAlignment.middle, - children: snapshot.data! - .map((item) => _buildTableRow(item)) - .toList() - ..insert( - 0, - _buildTableRow(Track( - number: -1, - title: 'Titre', - artiste: 'Artiste', - album: 'Album', - id: 'URL')), - ), - ); - } else { - return const Text('Empty data'); - } - } else { - return Text('State: ${snapshot.connectionState}'); - } - }, - ), - ], - ), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => PlayerProvider()), + ChangeNotifierProvider(create: (_) => HomeProvider()), + ], + child: MaterialApp( + title: 'Fipy', + theme: ThemeData( + primarySwatch: Colors.blue, backgroundColor: Colors.grey[900]), + home: const HomeScreen(title: 'Fipy'), ), ); } } - -Widget trackLine(Track track) { - return SizedBox( - height: 30, - child: Row( - children: [Text(track.title + ' - ' + track.artiste)], - ), - ); -} - -class Track { - final int number; - final String title; - final String artiste; - final String? album; - String? id; - - Track( - {required this.number, - required this.title, - required this.artiste, - this.album, - this.id}); -} - -GraphQLClient initClient() { - final _httpLink = HttpLink( - 'https://openapi.radiofrance.fr/v1/graphql?x-token=$token', - ); - - final GraphQLClient client = GraphQLClient( - cache: GraphQLCache(), - link: _httpLink, - ); - - return client; -} - -Future> getTracks() async { - final client = initClient(); - - final now = (DateTime.now().millisecondsSinceEpoch ~/ 1000); - final yesterday = now - (hours * 3600); - final String radioQuery; - if (radio == 'fip') { - radioQuery = 'FIP'; - } else { - radioQuery = 'FIP_${radio.toUpperCase()}'; - } - - final QueryOptions options = QueryOptions( - document: gql(getSongs), - pollInterval: const Duration(milliseconds: 50), - variables: { - 'start': yesterday, - 'end': now, - 'radio': radioQuery, - }, - ); - - QueryResult result; - result = await retry( - () async => await client - .query(options) - .timeout(const Duration(seconds: queryTimeout)), - onRetry: (_) => print('Timeout, retry...'), - ); - - if (result.hasException) { - print(result.exception.toString()); - } - - final List data = result.data?['grid'] ?? []; - print(data.length.toString() + ' songs'); - - // ####### - - List songsList = []; - int trackNbr = 0; - - for (Map track in data) { - track = track['track']; - trackNbr++; - final String title = track['title']; - final String artiste; - if (track['mainArtists'].isNotEmpty) { - artiste = track['mainArtists'].first; - } else { - artiste = ''; - } - final String album = track['albumTitle']; - - final thisSong = - Track(number: trackNbr, title: title, artiste: artiste, album: album); - songsList.add(thisSong); - - // resultUrl = yt.search.search(title + ' ' + artiste); - - // resultUrlPool.add(resultUrl); - - // resultUrl.then((value) { - // try { - // thisSong.url = value.first.url; - // } catch (e) { - // thisSong.url = ''; - // } - // songsList.add(thisSong); - // }); - } - - // for (var searchTrack in resultUrlPool) { - // await searchTrack; - // } - - songsList.sort((a, b) => a.number.compareTo(b.number)); - - return songsList; -} - -TableRow _buildTableRow(Track track) { - final textStyle = TextStyle( - fontWeight: track.number == -1 ? FontWeight.w700 : FontWeight.normal, - color: Colors.grey[350]); - const rowPadding = EdgeInsets.all(10); - var yt = YoutubeExplode(); - - return TableRow( - decoration: BoxDecoration( - color: Colors.grey[850], - ), - children: [ - TableCell( - child: Padding( - padding: rowPadding, - child: InkWell( - onTap: () async { - if (track.id == null) { - final resultUrl = await yt.search - .search(track.title + ' ' + track.artiste); - track.id = resultUrl.first.id.value; - } - if (track.id != null) { - // Map header = { - // 'Access-Control-Allow-Origin': '*', - // }; - final response = await http.get( - Uri.parse('https://ytdl.p2p.legal/ytdl.sh?${track.id}'), - ); - final dlLink = response.body.replaceFirst('/dl/', '/play/'); - print(dlLink); - player?.dispose(); - Future.delayed(const Duration(milliseconds: 5)); - player = Player.network(dlLink, autoPlay: true); - // player = Player.asset("assets/uka.mp3"); - player!.play(); - } - }, - child: Text(track.title, style: textStyle), - )), - ), - TableCell( - child: Padding( - padding: rowPadding, - child: Text(track.artiste, style: textStyle), - ), - ), - TableCell( - child: Padding( - padding: rowPadding, - child: Text(track.album ?? '', style: textStyle), - ), - ), - TableCell( - child: Padding( - padding: rowPadding, - child: InkWell( - onTap: () async { - if (track.id == null) { - final resultUrl = await yt.search - .search(track.title + ' ' + track.artiste); - track.id = resultUrl.first.id.value; - } - if (track.id != null) { - final response = await http.get(Uri.parse( - 'https://ytdl.p2p.legal/ytdl.sh?${track.id}')); - final dlLink = response.body; - if (await canLaunchUrl(Uri.parse(dlLink))) { - launchUrl(Uri.parse(dlLink)); - } - Clipboard.setData(ClipboardData(text: dlLink)); - } - }, - child: Text('Télécharger', style: textStyle), - )), - ), - ]); -} - -TextStyle globalTextStyle = TextStyle(color: Colors.grey[350]); diff --git a/lib/providers/home.dart b/lib/providers/home.dart new file mode 100644 index 0000000..70d1844 --- /dev/null +++ b/lib/providers/home.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class HomeProvider with ChangeNotifier { + void reload() { + notifyListeners(); + } +} diff --git a/lib/providers/player.dart b/lib/providers/player.dart new file mode 100644 index 0000000..0c3b4ae --- /dev/null +++ b/lib/providers/player.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class PlayerProvider with ChangeNotifier { + void reload() { + notifyListeners(); + } +} diff --git a/lib/screens/home.dart b/lib/screens/home.dart new file mode 100644 index 0000000..58b4e63 --- /dev/null +++ b/lib/screens/home.dart @@ -0,0 +1,530 @@ +// ignore_for_file: prefer_const_literals_to_create_immutables, avoid_print + +import 'package:fip_parser_ui/globals.dart'; +import 'package:fip_parser_ui/providers/home.dart'; +import 'package:fip_parser_ui/providers/player.dart'; +import 'package:flutter/material.dart'; +import 'package:fip_parser_ui/queries.dart'; +import 'package:graphql/client.dart'; +import 'package:kplayer/kplayer.dart'; +import 'package:miniplayer/miniplayer.dart'; +import 'package:provider/provider.dart'; +import 'package:retry/retry.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:http/http.dart' as http; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +PlayerController? player; +Track? currentTrack; +List trackList = []; +String radio = 'groove'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key? key, required this.title}) : super(key: key); + final String title; + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + Widget build(BuildContext context) { + final MiniplayerController controller = MiniplayerController(); + const hours = 8; + + return Stack( + children: [ + Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + Container( + color: Colors.grey[900], + height: 50, + child: Center( + child: DropdownButton( + dropdownColor: Colors.grey[900], + value: radio, + style: TextStyle(fontSize: 15, color: Colors.grey[300]), + underline: const SizedBox(), + iconSize: 22, + onChanged: (String? newRadio) { + setState(() { + radio = newRadio!; + getTracks(radio, hours); + }); + }, + items: radioList), + ), + ), + FutureBuilder>( + future: getTracks(radio, hours), + builder: ( + BuildContext context, + AsyncSnapshot> snapshot, + ) { + print(snapshot.connectionState); + if (snapshot.connectionState == ConnectionState.waiting) { + return Stack(children: [ + Container( + height: 10000, + width: 10000, + color: const Color(0xFF121212)), + Center( + child: Column(children: [ + const SizedBox(height: 20), + SizedBox( + height: 30, + width: 30, + child: CircularProgressIndicator( + color: Colors.amber[100], + ), + ), + ]), + ), + ]); + } else if (snapshot.connectionState == + ConnectionState.done) { + if (snapshot.hasError) { + return const Text('Error'); + } else if (snapshot.hasData) { + return Table( + defaultVerticalAlignment: + TableCellVerticalAlignment.middle, + children: snapshot.data! + .map((item) => _buildTableRow(item, context)) + .toList() + ..insert( + 0, + _buildTableRow( + Track( + number: -1, + title: 'TITRE', + artiste: 'ARTISTE', + album: 'ALBUM', + id: 'URL'), + context), + ), + ); + } else { + return const Text('Empty data'); + } + } else { + return Text('State: ${snapshot.connectionState}'); + } + }, + ), + ], + ), + ), + ), + Consumer(builder: (context, playerProvider, _) { + TextEditingController trackTitle = TextEditingController(); + TextEditingController trackArtiste = TextEditingController(); + trackTitle.text = currentTrack?.title ?? ''; + trackArtiste.text = currentTrack?.artiste ?? ''; + return Miniplayer( + controller: controller, + backgroundColor: Colors.grey[900]!, + minHeight: 70, + maxHeight: 370, + builder: (height, percentage) { + return Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + currentTrack?.id != null + ? Image.network( + 'https://img.youtube.com/vi/${currentTrack?.id}/1.jpg', + width: 93, + ) + : const SizedBox(width: 100), + Expanded( + child: Column(children: [ + SizedBox( + height: 20, + child: TextField( + enabled: false, + maxLines: 1, + controller: trackTitle, + decoration: const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.only(left: 15), + ), + style: TextStyle( + fontSize: 14, + color: Colors.grey[300], + ), + ), + ), + SizedBox( + height: 20, + child: TextField( + enabled: false, + maxLines: 1, + controller: trackArtiste, + decoration: const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.only(left: 15), + ), + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + ), + ]), + ), + IconButton( + icon: Icon(Icons.skip_previous, + color: Colors.grey[500], size: 32), + onPressed: () { + if (currentTrack != null && currentTrack!.number > 1) { + currentTrack = trackList.firstWhere((element) => + element.number == currentTrack!.number - 1); + playTrack(context, currentTrack!); + } + }), + ElevatedButton( + onPressed: () { + player?.playing ?? false + ? player?.pause() + : player?.play(); + playerProvider.reload(); + }, + child: Icon( + player?.playing ?? false + ? Icons.pause + : Icons.play_arrow, + color: Colors.grey[900], + size: 32), + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + padding: const EdgeInsets.all(12), + primary: Colors.grey[300], + onPrimary: Colors.grey[900], + ), + ), + IconButton( + icon: Icon(Icons.skip_next, + color: Colors.grey[500], size: 32), + onPressed: () { + if (currentTrack != null && + currentTrack!.number < trackList.last.number) { + currentTrack = trackList.firstWhere((element) => + element.number == currentTrack!.number + 1); + playTrack(context, currentTrack!); + } + }), + const Spacer(), + SliderTheme( + data: const SliderThemeData( + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + overlayShape: + RoundSliderThumbShape(enabledThumbRadius: 8), + trackHeight: 2.5, + ), + child: SizedBox( + width: 130, + child: Slider( + value: player?.volume ?? 1, + max: 1, + onChanged: (double value) { + if (player?.volume != null) { + player!.volume = value; + playerProvider.reload(); + } + }, + activeColor: Colors.grey[400], + inactiveColor: Colors.grey[700], + ), + ), + ), + const SizedBox(width: 20) + ], + ); + }, + ); + }) + ], + ); + } +} + +Widget trackLine(Track track) { + return SizedBox( + height: 30, + child: Row( + children: [Text(track.title + ' - ' + track.artiste)], + ), + ); +} + +class Track { + final int number; + final String title; + final String artiste; + final String? album; + String? id; + Duration? duration; + + Track( + {required this.number, + required this.title, + required this.artiste, + this.album, + this.id, + this.duration}); +} + +GraphQLClient initClient() { + final _httpLink = HttpLink( + 'https://openapi.radiofrance.fr/v1/graphql?x-token=$token', + ); + + final GraphQLClient client = GraphQLClient( + cache: GraphQLCache(), + link: _httpLink, + ); + + return client; +} + +Future> getTracks(String radio, int hours) async { + const int queryTimeout = 8; + + final client = initClient(); + + final now = (DateTime.now().millisecondsSinceEpoch ~/ 1000); + final yesterday = now - (hours * 3600); + final String radioQuery; + if (radio == 'fip') { + radioQuery = 'FIP'; + } else { + radioQuery = 'FIP_${radio.toUpperCase()}'; + } + + final QueryOptions options = QueryOptions( + document: gql(getSongs), + pollInterval: const Duration(milliseconds: 50), + variables: { + 'start': yesterday, + 'end': now, + 'radio': radioQuery, + }, + ); + + QueryResult result; + result = await retry( + () async => await client + .query(options) + .timeout(const Duration(seconds: queryTimeout)), + onRetry: (_) => print('Timeout, retry...'), + ); + + if (result.hasException) { + print(result.exception.toString()); + } + + final List data = result.data?['grid'] ?? []; + print(data.length.toString() + ' songs'); + + // ####### + + int trackNbr = 0; + + trackList.clear(); + final yt = YoutubeExplode(); + List resultUrlPool = []; + + for (Map track in data) { + track = track['track']; + trackNbr++; + final String title = track['title']; + final String artiste; + if (track['mainArtists'].isNotEmpty) { + artiste = track['mainArtists'].first; + } else { + artiste = ''; + } + final String album = track['albumTitle']; + + final thisTrack = + Track(number: trackNbr, title: title, artiste: artiste, album: album); + trackList.add(thisTrack); + + final secondMatch = artiste == '' ? album : artiste; + final resultUrl = yt.search.search(title + ' ' + secondMatch); + + resultUrlPool.add(resultUrl); + + resultUrl.then((value) { + try { + trackList[trackNbr - 1].id = value.first.id.value; + } catch (e) { + print( + 'Error: ' + trackList[trackNbr - 1].title + ' -> ' + e.toString()); + } + }); + } + + trackList.sort((a, b) => a.number.compareTo(b.number)); + + final secondMatch = + trackList[0].artiste == '' ? trackList[0].album : trackList[0].artiste; + yt.search.search(trackList[0].title + ' ' + secondMatch!).then((resultUrl) { + trackList[0].id = resultUrl.first.id.value; + + player = Player.network( + "https://invidious.fdn.fr/embed/${trackList[0].id}?raw=1&?listen=1"); + currentTrack = trackList[0]; + }); + return trackList; +} + +TableRow _buildTableRow(Track track, BuildContext context) { + final textStyle = TextStyle( + fontWeight: track.number == -1 ? FontWeight.w200 : FontWeight.normal, + color: Colors.grey[500], + fontSize: track.number == -1 ? 15 : 14); + const rowPadding = EdgeInsets.all(10); + final yt = YoutubeExplode(); + + return TableRow( + decoration: const BoxDecoration( + color: Color(0xFF121212), + ), + children: [ + TableCell( + child: Padding( + padding: rowPadding, + child: InkWell( + onTap: () async { + if (track.number != -1) { + playTrack(context, track); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Consumer( + builder: (context, playerProvider, _) { + return Text(track.title, + style: TextStyle( + fontWeight: FontWeight.normal, + color: currentTrack == track + ? Colors.green + : Colors.grey[350], + fontSize: 14)); + }), + const SizedBox(height: 5), + if (track.number != -1) + Text(track.artiste, + style: TextStyle( + fontWeight: FontWeight.normal, + color: Colors.grey[500], + fontSize: 13)) + ]), + )), + ), + TableCell( + child: Padding( + padding: rowPadding, + child: Text(track.album ?? '', style: textStyle), + ), + ), + TableCell( + child: Padding( + padding: rowPadding, + child: InkWell( + onTap: () async { + if (track.id == null) { + final secondMatch = + track.artiste == '' ? track.album : track.artiste; + final resultUrl = await yt.search + .search(track.title + ' ' + secondMatch!); + track.id = resultUrl.first.id.value; + } + if (track.id != null) { + final response = await http.get(Uri.parse( + 'https://ytdl.p2p.legal/ytdl.sh?${track.id}')); + final dlLink = response.body; + if (await canLaunchUrl(Uri.parse(dlLink))) { + launchUrl(Uri.parse(dlLink)); + } + } + }, + child: Text('TÉLÉCHARGER', style: textStyle), + )), + ), + ]); +} + +Future playTrack(BuildContext context, Track track) async { + var yt = YoutubeExplode(); + PlayerProvider playerProvider = + Provider.of(context, listen: false); + HomeProvider homeProvider = Provider.of(context, listen: false); + + track = trackList[track.number - 1]; + + if (track.id == null) { + final secondMatch = track.artiste == '' ? track.album : track.artiste; + final resultUrl = await yt.search.search(track.title + ' ' + secondMatch!); + track.id = resultUrl.first.id.value; + } + player?.dispose(); + Future.delayed(const Duration(milliseconds: 5)); + player = Player.network( + "https://invidious.fdn.fr/embed/${track.id}?raw=1&?listen=1"); + print(track.id); + + try { + player!.play(); + } catch (e) { + print('Play error: ' + e.toString()); + } + Future.delayed(const Duration(milliseconds: 50)); + player!.callback = (PlayerEvent event) { + if (event.name == 'status') { + var nextTrack = + trackList.firstWhere((element) => element.number == track.number + 1); + playTrack(context, nextTrack); + } + }; + currentTrack = track; + playerProvider.reload(); + homeProvider.reload(); +} + +// final radioList = [ +// 'fip', +// 'electro', +// 'groove', +// 'rock', +// 'jazz', +// 'pop', +// 'reggae', +// 'world', +// 'nouveautes', +// ]; + +List> get radioList { + List> menuItems = [ + const DropdownMenuItem(child: Text("FIP"), value: "fip"), + const DropdownMenuItem(child: Text("Electro"), value: "electro"), + const DropdownMenuItem(child: Text("Groove"), value: "groove"), + const DropdownMenuItem(child: Text("Rock"), value: "rock"), + const DropdownMenuItem(child: Text("Jazz"), value: "jazz"), + const DropdownMenuItem(child: Text("Pop"), value: "pop"), + const DropdownMenuItem(child: Text("Reggae"), value: "reggae"), + const DropdownMenuItem(child: Text("World"), value: "world"), + const DropdownMenuItem(child: Text("Nouveautes"), value: "nouveautes"), + ]; + return menuItems; +} diff --git a/pubspec.lock b/pubspec.lock index aecce3d..fe68c73 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,6 +317,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + miniplayer: + dependency: "direct main" + description: + path: "/home/poka/dev/flutter/miniplayer" + relative: false + source: path + version: "1.0.1" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" normalize: dependency: transitive description: @@ -408,6 +422,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.4" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.2" retry: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 95c6f88..b807646 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,13 +12,15 @@ dependencies: sdk: flutter cupertino_icons: ^1.0.2 graphql: ^5.0.0 - # youtube_explode_dart: ^1.10.9+1 - youtube_explode_dart: + youtube_explode_dart: #^1.10.9+1 path: /home/poka/dev/flutter/youtube_explode_dart retry: ^3.1.0 url_launcher: ^6.1.0 http: ^0.13.4 kplayer: ^0.1.12 + miniplayer: # ^1.0.1 + path: /home/poka/dev/flutter/miniplayer + provider: ^6.0.1 dev_dependencies: flutter_test: