feat: implement lyrics fetching and finalize first app version

This commit is contained in:
2022-03-04 23:16:29 +02:00
parent dc186a3037
commit ca9437e3c4
8 changed files with 506 additions and 77 deletions
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert' as convert;
class LyricsPage extends StatefulWidget {
const LyricsPage({
Key? key,
required this.title,
required this.artist,
required this.url,
}) : super(key: key);
final String title;
final String artist;
final String url;
@override
State<StatefulWidget> createState() => _LyricsPageState();
}
class _LyricsPageState extends State<LyricsPage> {
bool fetching = false;
String? lyrics;
@override
void initState() {
super.initState();
initPlatformState();
}
Future<void> 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<String, dynamic>;
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,
),
),
),
),
),
),
);
}
}
+141 -61
View File
@@ -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<void> main() async {
runApp(const LyricsGrabbr());
@@ -33,10 +36,11 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
late AndroidNotificationListener _notifications;
late StreamSubscription<NotificationEvent> _subscription;
BuildContext? permissionDialogCtx;
NotificationEvent? currentlyPlaying;
NotificationEvent? lastNotification;
bool running = false;
bool searching = false;
final List<SearchResult> searchResults = [];
@override
void initState() {
@@ -79,24 +83,24 @@ class _HomePageState extends State<HomePage> 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<HomePage> 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<String, dynamic>;
List<dynamic> 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<HomePage> 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),
),
);
}
}
+10 -13
View File
@@ -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<dynamic, dynamic> 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<NotificationEvent> get notificationStream {
if (Platform.isAndroid) {
Stream<NotificationEvent> _notificationStream = _notificationEventChannel
.receiveBroadcastStream()
.map((event) => _notificationEvent(event));
+172
View File
@@ -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<String, dynamic> 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<StatefulWidget> 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<SearchResult> {
List<Widget> _buildOptions() {
return <OptionItem>[
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,
),
],
),
),
],
),
),
),
),
);
}
}