diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 52685e2..9e9d4e8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,15 @@ package="com.illusionware.lyricsgrabbr.lyricsgrabbr"> + + + + + + + + createState() => _LyricsPageState(); +} + +class _LyricsPageState extends State { + bool fetching = false; + String? lyrics; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + Future initPlatformState() async { + setState(() => fetching = true); + + final uri = Uri.https('api.illusionman1212.tech', '/lyrics', { + 'songUrl': widget.url, + }); + + var response = await http.get(uri); + if (response.statusCode == 200) { + final jsonRes = convert.jsonDecode(response.body) as Map; + + setState(() { + fetching = false; + lyrics = jsonRes['lyrics']; + }); + } else { + print('error while GETing lyrics'); + setState(() => fetching = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.artist, + style: const TextStyle( + fontSize: 13, + ), + ), + ], + ), + ), + body: Center( + child: fetching + ? Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [CircularProgressIndicator(value: null)], + ), + ), + ], + ) + : SizedBox( + width: MediaQuery.of(context).size.width, + child: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 30, vertical: 30), + child: Text( + lyrics ?? '', + style: const TextStyle( + fontSize: 16, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 26f4241..3a12bde 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'notification_listener.dart'; +import 'search_result.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert' as convert; Future main() async { runApp(const LyricsGrabbr()); @@ -33,10 +36,11 @@ class HomePage extends StatefulWidget { class _HomePageState extends State with WidgetsBindingObserver { late AndroidNotificationListener _notifications; - late StreamSubscription _subscription; BuildContext? permissionDialogCtx; - NotificationEvent? currentlyPlaying; + NotificationEvent? lastNotification; bool running = false; + bool searching = false; + final List searchResults = []; @override void initState() { @@ -79,24 +83,24 @@ class _HomePageState extends State with WidgetsBindingObserver { SystemNavigator.pop(); return Future.value(false); }, - child: AlertDialog( - title: const Text( - 'Notifications Access Permission', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: const Text( - 'This app requires permission to listen to notifications to function properly. Please grant permission in the next screen.', - style: TextStyle(color: Colors.blueGrey), - ), - actions: [ - TextButton( - onPressed: () async { - await AndroidNotificationListener - .openPermissionSettings(); - }, - child: const Text("OK")), - ], + child: AlertDialog( + title: const Text( + 'Notifications Access Permission', + style: TextStyle(fontWeight: FontWeight.bold), ), + content: const Text( + 'This app requires permission to listen to notifications to function properly. Please grant permission in the next screen. BEWARE that the only way to stop the service is to revoke the notification listening permission.', + style: TextStyle(color: Colors.blueGrey), + ), + actions: [ + TextButton( + onPressed: () async { + await AndroidNotificationListener + .openPermissionSettings(); + }, + child: const Text('OK')), + ], + ), ); }); } @@ -112,21 +116,74 @@ class _HomePageState extends State with WidgetsBindingObserver { } void onData(NotificationEvent event) { - setState(() { - currentlyPlaying = event; - }); + if (event.playbackState == PlaybackState.STATE_INVALID) { + return; + } + + if (lastNotification?.songTitle != event.songTitle && + lastNotification?.artist != event.artist) { + searchLyrics(event.songTitle, event.artist); + + setState(() { + searchResults.clear(); + lastNotification = event; + }); + } } void startListening() { _notifications = AndroidNotificationListener(); - _subscription = _notifications.notificationStream.listen(onData); + _notifications.notificationStream.listen(onData); setState(() => running = true); } - void stopListening() { - setState(() => running = false); - _subscription.cancel(); + void searchLyrics( + String song, + String artist, + ) async { + setState(() { + searching = true; + }); + + // TODO: build an httpclient to reuse between requests. + final uri = Uri.https( + 'api.illusionman1212.tech', + '/lyrics/search', + {'q': '$song $artist'}, + ); + + var response = await http.get(uri); + if (response.statusCode == 200) { + final jsonRes = convert.jsonDecode(response.body) as Map; + List results = jsonRes['all']; + + for (var result in results) { + var myResult = SearchResult.fromJson(result); + setState(() { + searchResults.add(myResult); + }); + } + + setState(() { + searching = false; + }); + } else { + // TODO: display error + print('error in http get'); + setState(() { + searching = false; + }); + } + } + + Widget _buildResults() { + return ListView.builder( + itemBuilder: (_, i) { + return searchResults[i]; + }, + itemCount: searchResults.length, + ); } @override @@ -134,52 +191,75 @@ class _HomePageState extends State with WidgetsBindingObserver { return Scaffold( appBar: AppBar( title: Text(widget.title), + centerTitle: true, ), - body: Container( - padding: const EdgeInsets.all(20), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - running ? RichText( - text: TextSpan( - text: currentlyPlaying?.songTitle, - style: const TextStyle( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (running) + Container( + padding: const EdgeInsets.all(20), + child: RichText( + text: TextSpan( + text: lastNotification?.songTitle, + style: const TextStyle( fontSize: 24.0, color: Colors.black, fontWeight: FontWeight.w700, - ), - children: [ - const TextSpan( - text: "\n", ), - TextSpan( - text: currentlyPlaying?.artist, + children: [ + const TextSpan( + text: '\n', + ), + TextSpan( + text: lastNotification?.artist, style: const TextStyle( fontSize: 20.0, color: Color(0xFF303030), - ) - ) - ] + ), + ) + ]), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ) : - const Text("Listener stopped"), - running ? Text( - currentlyPlaying?.playbackState == PlaybackState.STATE_PLAYING ? "Spotify Playing" : "Spotify Paused", - style: Theme.of(context).textTheme.headline6, - ) : - const Text("") - ], - ), + ), + Container( + child: searching + ? Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + CircularProgressIndicator(value: null) + ])) + : Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + margin: const EdgeInsets.symmetric(vertical: 10), + child: Text( + running + ? 'Found ${searchResults.length} result(s)' + : "", + style: const TextStyle( + fontSize: 15, + color: Colors.black87, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ), + ), + ), + Flexible(child: _buildResults()), + ], + ), + ), + ), + ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: running ? stopListening : startListening, - tooltip: running ? 'Stop listener' : "Start listener", - child: running ? const Icon(Icons.stop) : const Icon(Icons.play_arrow), - ), ); } } diff --git a/lib/notification_listener.dart b/lib/notification_listener.dart index 93642d9..1d954d3 100644 --- a/lib/notification_listener.dart +++ b/lib/notification_listener.dart @@ -28,14 +28,13 @@ class NotificationEvent { final DateTime timeStamp; - const NotificationEvent({ - required this.packageName, - required this.artist, - required this.timeStamp, - required this.songTitle, - required this.playbackState, - required this.duration - }); + const NotificationEvent( + {required this.packageName, + required this.artist, + required this.timeStamp, + required this.songTitle, + required this.playbackState, + required this.duration}); factory NotificationEvent.fromMap(Map map) { DateTime time = DateTime.now(); @@ -51,8 +50,7 @@ class NotificationEvent { timeStamp: time, songTitle: text, playbackState: playbackState, - duration: duration - ); + duration: duration); } } @@ -62,13 +60,12 @@ NotificationEvent _notificationEvent(dynamic data) { class AndroidNotificationListener { static const EventChannel _notificationEventChannel = - EventChannel('notifications.eventChannel'); + EventChannel('notifications.eventChannel'); static const MethodChannel _methodChannel = - MethodChannel('notifications/method'); + MethodChannel('notifications/method'); Stream get notificationStream { if (Platform.isAndroid) { - Stream _notificationStream = _notificationEventChannel .receiveBroadcastStream() .map((event) => _notificationEvent(event)); diff --git a/lib/search_result.dart b/lib/search_result.dart new file mode 100644 index 0000000..9e2af4b --- /dev/null +++ b/lib/search_result.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'lyrics.dart'; + +class SearchResult extends StatefulWidget { + const SearchResult( + {Key? key, + required this.title, + required this.artist, + required this.url, + required this.artistUrl, + required this.thumbnail, + required this.id}) + : super(key: key); + + final String title; + final String artist; + final String url; + final String artistUrl; + final String thumbnail; + final int id; + + SearchResult.fromJson(Map json, {Key? key}) + : title = json['meta']['title'], + artist = json['meta']['primaryArtist']['name'], + url = json['url'], + artistUrl = json['meta']['primaryArtist']['url'], + thumbnail = json['resources']['thumbnail'], + id = json['id'], + super(key: key); + + @override + State createState() => _SearchResultState(); +} + +class OptionItem extends StatelessWidget { + final String title; + final IconData icon; + final void Function()? tapFunction; + + const OptionItem( + {Key? key, required this.title, required this.icon, this.tapFunction}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: tapFunction, + child: Ink( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 15), + child: Icon(icon), + ), + Text( + title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ); + } +} + +class _SearchResultState extends State { + List _buildOptions() { + return [ + OptionItem( + title: "Open Artist Page in Genius", + icon: Icons.library_music, + tapFunction: () async { + if (!await launch(widget.artistUrl)) { + throw 'could not launch ${widget.artistUrl}'; + } + }), + OptionItem( + title: "Open Song Page in Genius", + icon: Icons.music_note, + tapFunction: () async { + if (!await launch(widget.url)) { + throw 'could not launch ${widget.url}'; + } + }) + ]; + } + + void _showOptionsBottomSheet() { + showModalBottomSheet( + context: context, + builder: (context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + (MediaQuery.of(context).size.height - kToolbarHeight) / 2, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: _buildOptions(), + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Container( + margin: const EdgeInsets.only(top: 0.5), + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LyricsPage( + title: widget.title, + artist: widget.artist, + url: widget.url, + ), + ), + ); + }, + onLongPress: () => _showOptionsBottomSheet(), + child: Ink( + height: 75, + color: Colors.black, + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 20), + child: Image.network( + widget.thumbnail, + width: 75, + height: 75, + fit: BoxFit.cover, + ), + ), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + widget.artist, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2926e08..45803da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -116,6 +116,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" http: dependency: "direct main" description: @@ -130,6 +135,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" lints: dependency: transitive description: @@ -254,6 +266,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.20" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.15" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.15" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" vector_math: dependency: transitive description: @@ -277,4 +345,4 @@ packages: version: "5.3.1" sdks: dart: ">=2.16.1 <3.0.0" - flutter: ">=2.2.0" + flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 474a2c0..3bdc505 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. http: ^0.13.4 flutter_local_notifications: ^9.3.2 + url_launcher: ^6.0.20 dev_dependencies: flutter_test: