mirror of
https://github.com/IllusionMan1212/lyrics-grabbr.git
synced 2026-10-24 02:12:27 +00:00
feat: implement lyrics fetching and finalize first app version
This commit is contained in:
+105
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user