Skip to content

Commit 4e4383b

Browse files
committed
feat: add Discord Rich Presence integration
- Add DiscordRpcService with token management, cover art upload, and RPC state - Add DiscordLoginPage using in-app WebView OAuth flow - Wire DiscordRpcService init into main app startup - Add Discord connect/disconnect UI to settings page - Add deps: flutter_inappwebview, http, crypto
1 parent 61cdc67 commit 4e4383b

9 files changed

Lines changed: 983 additions & 2 deletions

File tree

lib/main.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:sono/pages/app_shell.dart';
77
import 'package:sono/services/audio_handler.dart';
88
import 'package:sono/services/audio_service.dart' as sono;
99
import 'package:sono/services/audio_effects_service.dart';
10+
import 'package:sono/services/discord_rpc/discord_rpc_service.dart';
1011

1112
import 'package:sono/theme/tokens.dart';
1213
import 'package:sono/theme/theme.dart';
@@ -38,6 +39,9 @@ void main() async {
3839
sono.AudioService.instance.attachDb(db);
3940
await sono.AudioService.instance.loadState();
4041

42+
DiscordRpcService.instance.attachDb(db);
43+
await DiscordRpcService.instance.loadState();
44+
4145
runApp(SonoApp(db: db));
4246
}
4347

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
3+
4+
/// Extract user token from localStorage
5+
///
6+
/// Open discord.com/login. Once the user logs in and lands on /app,
7+
/// evaluate JS to grab the token from localStorage
8+
class DiscordLoginPage extends StatefulWidget {
9+
const DiscordLoginPage({super.key});
10+
11+
@override
12+
State<DiscordLoginPage> createState() => _DiscordLoginPageState();
13+
}
14+
15+
class _DiscordLoginPageState extends State<DiscordLoginPage> {
16+
bool _loading = true;
17+
bool _extracted = false;
18+
19+
@override
20+
Widget build(BuildContext context) {
21+
return Scaffold(
22+
appBar: AppBar(
23+
title: const Text('Sign in'),
24+
leading: IconButton(
25+
icon: const Icon(Icons.close_rounded),
26+
onPressed: () => Navigator.pop(context),
27+
),
28+
),
29+
body: Stack(
30+
children: [
31+
InAppWebView(
32+
initialUrlRequest: URLRequest(
33+
url: WebUri('https://discord.com/login'),
34+
),
35+
initialSettings: InAppWebViewSettings(
36+
javaScriptEnabled: true,
37+
domStorageEnabled: true,
38+
//clear cache to ensure fresh login
39+
clearCache: false,
40+
userAgent:
41+
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
42+
'(KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36',
43+
),
44+
onWebViewCreated: (controller) {
45+
//inject JS on page start to preserve token
46+
//discords JS tries to remove the token form localStorage
47+
//on certain navigations. hook removeItem to prevent that
48+
controller.addUserScript(
49+
userScript: UserScript(
50+
source: '''
51+
(function() {
52+
window.__SONO_LS = localStorage;
53+
var origRemove = localStorage.removeItem.bind(localStorage);
54+
localStorage.removeItem = function(key) {
55+
if (key === 'token') return true;
56+
return origRemove(key);
57+
};
58+
})();
59+
''',
60+
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
61+
),
62+
);
63+
},
64+
onLoadStop: (controller, url) async {
65+
setState(() => _loading = false);
66+
await _tryExtractToken(controller, url);
67+
},
68+
onUpdateVisitedHistory: (controller, url, androidIsReload) async {
69+
await _tryExtractToken(controller, url);
70+
},
71+
),
72+
if (_loading) const Center(child: CircularProgressIndicator()),
73+
],
74+
),
75+
);
76+
}
77+
78+
Future<void> _tryExtractToken(
79+
InAppWebViewController controller,
80+
WebUri? url,
81+
) async {
82+
final urlStr = url?.toString() ?? '';
83+
if (!urlStr.contains('discord.com/app') &&
84+
!urlStr.contains('discord.com/channels')) {
85+
return;
86+
}
87+
if (_extracted) return;
88+
_extracted = true;
89+
90+
final navigator = Navigator.of(context);
91+
92+
//small delay to let discord JS set token
93+
await Future.delayed(const Duration(milliseconds: 500));
94+
95+
final result = await controller.evaluateJavascript(
96+
source: '(function() { return window.__SONO_LS.getItem("token"); })()',
97+
);
98+
99+
if (result != null && mounted) {
100+
//result comes back as a quoted string like '"abc..."'
101+
String token = result.toString();
102+
//strip surrounding quotes if present
103+
if (token.startsWith('"') && token.endsWith('"')) {
104+
token = token.substring(1, token.length - 1);
105+
}
106+
107+
if (token.length > 50) {
108+
navigator.pop(token);
109+
} else {
110+
_showError('Could not extract token (length: ${token.length})');
111+
_extracted = false;
112+
}
113+
}
114+
}
115+
116+
void _showError(String msg) {
117+
if (!mounted) return;
118+
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
119+
}
120+
}

lib/pages/settings/settings_page.dart

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import 'package:flutter/material.dart';
22
import 'package:sono/db/database.dart';
3+
import 'package:sono/pages/auth/discord_login_page.dart';
34
import 'package:sono/services/scan_settings.dart';
5+
import 'package:sono/services/discord_rpc/discord_rpc_service.dart';
46
import 'package:sono_query/sono_query.dart' hide Song;
57

68
class SettingsPage extends StatefulWidget {
@@ -19,10 +21,16 @@ class _SettingsPageState extends State<SettingsPage> {
1921
final _artistCtrl = TextEditingController();
2022
final _delimiterCtrl = TextEditingController();
2123

24+
//discord RPC state
25+
bool _discordEnabled = false;
26+
String? _discordUsername;
27+
bool _discordLoading = false;
28+
2229
@override
2330
void initState() {
2431
super.initState();
2532
_load();
33+
_loadDiscord();
2634
}
2735

2836
@override
@@ -45,6 +53,61 @@ class _SettingsPageState extends State<SettingsPage> {
4553
widget.onRescan?.call();
4654
}
4755

56+
Future<void> _loadDiscord() async {
57+
final rpc = DiscordRpcService.instance;
58+
final enabled = rpc.isEnabled;
59+
String? username;
60+
if (enabled) {
61+
final stored = await widget.db.getSetting('discord.username');
62+
username = stored;
63+
}
64+
if (mounted) {
65+
setState(() {
66+
_discordEnabled = enabled;
67+
_discordUsername = username;
68+
});
69+
}
70+
}
71+
72+
Future<void> _discordLogin() async {
73+
final token = await Navigator.push<String>(
74+
context,
75+
MaterialPageRoute(builder: (_) => const DiscordLoginPage()),
76+
);
77+
if (token == null || !mounted) return;
78+
79+
setState(() => _discordLoading = true);
80+
try {
81+
final user = await DiscordRpcService.instance.login(token);
82+
await widget.db.setSetting('discord.username', '@${user.username}');
83+
if (mounted) {
84+
setState(() {
85+
_discordEnabled = true;
86+
_discordUsername = '@${user.username}';
87+
_discordLoading = false;
88+
});
89+
}
90+
} catch (e) {
91+
if (mounted) {
92+
setState(() => _discordLoading = false);
93+
ScaffoldMessenger.of(
94+
context,
95+
).showSnackBar(SnackBar(content: Text('Discord login failed: $e')));
96+
}
97+
}
98+
}
99+
100+
Future<void> _discordLogout() async {
101+
await DiscordRpcService.instance.logout();
102+
await widget.db.removeSetting('discord.username');
103+
if (mounted) {
104+
setState(() {
105+
_discordEnabled = false;
106+
_discordUsername = null;
107+
});
108+
}
109+
}
110+
48111
@override
49112
Widget build(BuildContext context) {
50113
final c = _config;
@@ -218,6 +281,43 @@ class _SettingsPageState extends State<SettingsPage> {
218281
),
219282
),
220283
],
284+
285+
const SizedBox(height: 32),
286+
const Divider(),
287+
const SizedBox(height: 12),
288+
if (_discordLoading)
289+
const Padding(
290+
padding: EdgeInsets.symmetric(vertical: 16),
291+
child: Center(child: CircularProgressIndicator()),
292+
)
293+
else if (_discordEnabled) ...[
294+
ListTile(
295+
contentPadding: EdgeInsets.zero,
296+
title: Text(_discordUsername ?? 'Connected'),
297+
trailing: TextButton(
298+
onPressed: _discordLogout,
299+
child: const Text('Disconnect'),
300+
),
301+
),
302+
SwitchListTile(
303+
contentPadding: EdgeInsets.zero,
304+
title: const Text('Enabled'),
305+
value: _discordEnabled,
306+
onChanged: (val) async {
307+
await DiscordRpcService.instance.setEnabled(val);
308+
setState(() => _discordEnabled = val);
309+
},
310+
),
311+
] else
312+
ListTile(
313+
contentPadding: EdgeInsets.zero,
314+
title: const Text('Connect Discord'),
315+
subtitle: const Text('to show current song on discord.'),
316+
trailing: FilledButton(
317+
onPressed: _discordLogin,
318+
child: const Text('Sign in'),
319+
),
320+
),
221321
],
222322
);
223323
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import 'dart:convert';
2+
import 'dart:typed_data';
3+
import 'package:crypto/crypto.dart';
4+
import 'package:http/http.dart' as http;
5+
6+
/// Uploads cover art bytes to a temp file host so discord can display them
7+
///
8+
/// Uses tmpfiles.org. The URL is valid for one hour.
9+
class CoverUploader {
10+
static const _uploadUrl = 'https://tmpfiles.org/api/v1/upload';
11+
static const _urlTtl = Duration(hours: 1);
12+
13+
final _client = http.Client();
14+
15+
String? _lastHash;
16+
String? _lastUrl;
17+
DateTime? _lastUploadTime;
18+
19+
/// Upload image bytes and return public URL, or null on failure
20+
Future<String?> upload(Uint8List imageBytes) async {
21+
final hash = md5.convert(imageBytes).toString();
22+
23+
final expired =
24+
_lastUploadTime == null ||
25+
DateTime.now().difference(_lastUploadTime!) >= _urlTtl;
26+
27+
if (!expired && _lastHash == hash && _lastUrl != null) {
28+
return _lastUrl;
29+
}
30+
31+
try {
32+
final request = http.MultipartRequest('POST', Uri.parse(_uploadUrl))
33+
..files.add(
34+
http.MultipartFile.fromBytes(
35+
'file',
36+
imageBytes,
37+
filename: 'cover.jpg',
38+
),
39+
);
40+
41+
final response = await _client.send(request);
42+
final body = await response.stream.bytesToString();
43+
44+
if (response.statusCode != 200) return null;
45+
46+
final json = jsonDecode(body) as Map<String, dynamic>;
47+
final url = (json['data'] as Map<String, dynamic>?)?['url'] as String?;
48+
if (url == null) return null;
49+
50+
//tmpfiles returns /123/filename.jpg, need /dl/123/filename.jpg
51+
final publicUrl = url.replaceFirst('org/', 'org/dl/');
52+
53+
_lastHash = hash;
54+
_lastUrl = publicUrl;
55+
_lastUploadTime = DateTime.now();
56+
return publicUrl;
57+
} catch (_) {
58+
return null;
59+
}
60+
}
61+
62+
void dispose() => _client.close();
63+
}

0 commit comments

Comments
 (0)