Skip to content

Commit d876df1

Browse files
feat: support media keys
1 parent 1af3b51 commit d876df1

11 files changed

Lines changed: 768 additions & 91 deletions

lib/core/tidal_api.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ class TidalApi {
2929
).replace(queryParameters: queryParams);
3030
final response = await _httpClient.get(uri, headers: auth.apiHeaders);
3131

32-
if (response.statusCode == 401) {
33-
// Try refreshing and retry once
34-
await auth.ensureValidToken();
32+
if (response.statusCode == 401 || response.statusCode == 500) {
33+
await auth.ensureValidToken(force: true);
3534
final retryResponse = await _httpClient.get(
3635
uri,
3736
headers: auth.apiHeaders,
@@ -68,8 +67,8 @@ class TidalApi {
6867
).replace(queryParameters: queryParams);
6968
final response = await _httpClient.get(uri, headers: auth.apiHeaders);
7069

71-
if (response.statusCode == 401) {
72-
await auth.ensureValidToken();
70+
if (response.statusCode == 401 || response.statusCode == 500) {
71+
await auth.ensureValidToken(force: true);
7372
final retryResponse = await _httpClient.get(
7473
uri,
7574
headers: auth.apiHeaders,

lib/core/tidal_auth.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ class TidalAuth {
224224
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
225225
body: {
226226
'client_id': clientId,
227+
'client_secret': clientSecret,
227228
'grant_type': 'refresh_token',
228229
'refresh_token': _tokens!.refreshToken,
229230
},
@@ -273,9 +274,10 @@ class TidalAuth {
273274
}
274275

275276
/// Ensure token is valid, refreshing if needed.
276-
Future<void> ensureValidToken() async {
277+
/// If [force] is true, refreshes even if not expired.
278+
Future<void> ensureValidToken({bool force = false}) async {
277279
if (_tokens == null) throw Exception('Not logged in');
278-
if (_tokens!.isExpired) {
280+
if (force || _tokens!.isExpired) {
279281
final success = await _refreshToken();
280282
if (!success) throw Exception('Session expired. Please log in again.');
281283
}

lib/services/app_state.dart

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import '../core/tidal_auth.dart';
44
import '../core/tidal_api.dart';
55
import '../models/models.dart';
66
import 'audio_player.dart';
7+
import 'mpris_service.dart';
8+
9+
enum AlbumSortMode { title, artist, year }
710

811
/// Navigation destinations in the app.
912
enum NavDestination {
@@ -36,6 +39,27 @@ class AppState extends ChangeNotifier {
3639
NavDestination _currentNav = NavDestination.home;
3740
NavDestination get currentNav => _currentNav;
3841

42+
// ─── Sort state ──────────────────────────────────────────────────
43+
AlbumSortMode _albumSortMode = AlbumSortMode.title;
44+
bool _albumSortAscending = true;
45+
46+
AlbumSortMode get albumSortMode => _albumSortMode;
47+
bool get albumSortAscending => _albumSortAscending;
48+
49+
void setAlbumSort(AlbumSortMode mode) {
50+
if (mode == _albumSortMode) {
51+
_albumSortAscending = !_albumSortAscending;
52+
} else {
53+
_albumSortMode = mode;
54+
_albumSortAscending = true;
55+
}
56+
notifyListeners();
57+
}
58+
59+
// ─── Search state ──────────────────────────────────────────────
60+
String _searchQuery = '';
61+
String get searchQuery => _searchQuery;
62+
3963
// ─── Content state ──────────────────────────────────────────────
4064
SearchResults? _searchResults;
4165
List<Album> _favoriteAlbums = [];
@@ -102,11 +126,13 @@ class AppState extends ChangeNotifier {
102126
final List<_NavState> _navHistory = [];
103127

104128
late final AudioPlayerService audioPlayer;
129+
final MprisService _mpris = MprisService();
105130

106131
AppState() : auth = TidalAuth() {
107132
api = TidalApi(auth: auth);
108133
audioPlayer = AudioPlayerService(api: api);
109134
_listenToPlayer();
135+
_mpris.initialize(audioPlayer);
110136
}
111137

112138
void _listenToPlayer() {
@@ -223,6 +249,10 @@ class AppState extends ChangeNotifier {
223249
artist: _selectedArtist,
224250
playlist: _selectedPlaylist,
225251
composer: _selectedComposer,
252+
albumSortMode: _albumSortMode,
253+
albumSortAscending: _albumSortAscending,
254+
searchQuery: _searchQuery,
255+
searchResults: _searchResults,
226256
));
227257
_currentNav = dest;
228258
_selectedAlbum = null;
@@ -260,6 +290,10 @@ class AppState extends ChangeNotifier {
260290
_selectedArtist = prev.artist;
261291
_selectedPlaylist = prev.playlist;
262292
_selectedComposer = prev.composer;
293+
_albumSortMode = prev.albumSortMode;
294+
_albumSortAscending = prev.albumSortAscending;
295+
_searchQuery = prev.searchQuery;
296+
_searchResults = prev.searchResults;
263297
notifyListeners();
264298
}
265299
}
@@ -284,6 +318,7 @@ class AppState extends ChangeNotifier {
284318
}
285319

286320
Future<void> search(String query) async {
321+
_searchQuery = query;
287322
if (query.trim().isEmpty) {
288323
_searchResults = null;
289324
notifyListeners();
@@ -340,6 +375,8 @@ class AppState extends ChangeNotifier {
340375
}
341376

342377
Future<void> selectPlaylist(Playlist playlist) async {
378+
_selectedAlbum = null;
379+
_selectedArtist = null;
343380
_selectedPlaylist = playlist;
344381
_contentLoading = true;
345382
notifyListeners();
@@ -449,6 +486,20 @@ class _NavState {
449486
final Artist? artist;
450487
final Playlist? playlist;
451488
final String? composer;
452-
453-
_NavState({required this.nav, this.album, this.artist, this.playlist, this.composer});
489+
final AlbumSortMode albumSortMode;
490+
final bool albumSortAscending;
491+
final String searchQuery;
492+
final SearchResults? searchResults;
493+
494+
_NavState({
495+
required this.nav,
496+
this.album,
497+
this.artist,
498+
this.playlist,
499+
this.composer,
500+
this.albumSortMode = AlbumSortMode.title,
501+
this.albumSortAscending = true,
502+
this.searchQuery = '',
503+
this.searchResults,
504+
});
454505
}

lib/services/mpris_service.dart

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import 'package:dbus/dbus.dart';
2+
import 'package:flutter/foundation.dart';
3+
import 'audio_player.dart';
4+
5+
const _mprisInterface = 'org.mpris.MediaPlayer2';
6+
const _playerInterface = 'org.mpris.MediaPlayer2.Player';
7+
const _propertiesInterface = 'org.freedesktop.DBus.Properties';
8+
9+
class _MprisObject extends DBusObject {
10+
final AudioPlayerService audioPlayer;
11+
12+
_MprisObject(this.audioPlayer)
13+
: super(DBusObjectPath('/org/mpris/MediaPlayer2'));
14+
15+
@override
16+
Future<DBusMethodResponse> handleMethodCall(DBusMethodCall methodCall) async {
17+
if (methodCall.interface == _playerInterface) {
18+
switch (methodCall.name) {
19+
case 'PlayPause':
20+
audioPlayer.togglePlayPause();
21+
return DBusMethodSuccessResponse();
22+
case 'Play':
23+
audioPlayer.play();
24+
return DBusMethodSuccessResponse();
25+
case 'Pause':
26+
audioPlayer.pause();
27+
return DBusMethodSuccessResponse();
28+
case 'Next':
29+
audioPlayer.playNext();
30+
return DBusMethodSuccessResponse();
31+
case 'Previous':
32+
audioPlayer.playPrevious();
33+
return DBusMethodSuccessResponse();
34+
}
35+
}
36+
return DBusMethodErrorResponse.unknownMethod();
37+
}
38+
39+
@override
40+
Future<DBusMethodResponse> getProperty(String interface, String name) async {
41+
if (interface == _mprisInterface) {
42+
switch (name) {
43+
case 'CanQuit':
44+
return DBusGetPropertyResponse(const DBusBoolean(false));
45+
case 'CanRaise':
46+
return DBusGetPropertyResponse(const DBusBoolean(false));
47+
case 'HasTrackList':
48+
return DBusGetPropertyResponse(const DBusBoolean(false));
49+
case 'Identity':
50+
return DBusGetPropertyResponse(const DBusString('Torrential'));
51+
case 'SupportedUriSchemes':
52+
return DBusGetPropertyResponse(DBusArray.string([]));
53+
case 'SupportedMimeTypes':
54+
return DBusGetPropertyResponse(DBusArray.string([]));
55+
}
56+
}
57+
58+
if (interface == _playerInterface) {
59+
switch (name) {
60+
case 'PlaybackStatus':
61+
final status = audioPlayer.isPlaying ? 'Playing' : 'Paused';
62+
return DBusGetPropertyResponse(DBusString(status));
63+
case 'CanPlay':
64+
return DBusGetPropertyResponse(const DBusBoolean(true));
65+
case 'CanPause':
66+
return DBusGetPropertyResponse(const DBusBoolean(true));
67+
case 'CanGoNext':
68+
return DBusGetPropertyResponse(const DBusBoolean(true));
69+
case 'CanGoPrevious':
70+
return DBusGetPropertyResponse(const DBusBoolean(true));
71+
case 'CanSeek':
72+
return DBusGetPropertyResponse(const DBusBoolean(false));
73+
case 'CanControl':
74+
return DBusGetPropertyResponse(const DBusBoolean(true));
75+
case 'Metadata':
76+
return DBusGetPropertyResponse(_buildMetadata());
77+
}
78+
}
79+
80+
return DBusMethodErrorResponse.unknownProperty();
81+
}
82+
83+
@override
84+
Future<DBusMethodResponse> getAllProperties(String interface) async {
85+
if (interface == _mprisInterface) {
86+
return DBusGetAllPropertiesResponse({
87+
'CanQuit': const DBusBoolean(false),
88+
'CanRaise': const DBusBoolean(false),
89+
'HasTrackList': const DBusBoolean(false),
90+
'Identity': const DBusString('Torrential'),
91+
'SupportedUriSchemes': DBusArray.string([]),
92+
'SupportedMimeTypes': DBusArray.string([]),
93+
});
94+
}
95+
96+
if (interface == _playerInterface) {
97+
return DBusGetAllPropertiesResponse({
98+
'PlaybackStatus':
99+
DBusString(audioPlayer.isPlaying ? 'Playing' : 'Paused'),
100+
'CanPlay': const DBusBoolean(true),
101+
'CanPause': const DBusBoolean(true),
102+
'CanGoNext': const DBusBoolean(true),
103+
'CanGoPrevious': const DBusBoolean(true),
104+
'CanSeek': const DBusBoolean(false),
105+
'CanControl': const DBusBoolean(true),
106+
'Metadata': _buildMetadata(),
107+
});
108+
}
109+
110+
return DBusGetAllPropertiesResponse({});
111+
}
112+
113+
DBusValue _buildMetadata() {
114+
final track = audioPlayer.currentTrack;
115+
final entries = <DBusString, DBusValue>{
116+
const DBusString('mpris:trackid'): DBusObjectPath(
117+
'/org/mpris/MediaPlayer2/Track/${track?.id ?? 0}',
118+
),
119+
};
120+
121+
if (track != null) {
122+
entries[const DBusString('xesam:title')] =
123+
DBusString(track.displayTitle);
124+
entries[const DBusString('xesam:artist')] =
125+
DBusArray.string([track.artistNames]);
126+
if (track.album != null) {
127+
entries[const DBusString('xesam:album')] =
128+
DBusString(track.album!.title);
129+
}
130+
if (track.imageUrl.isNotEmpty) {
131+
entries[const DBusString('mpris:artUrl')] =
132+
DBusString(track.imageUrl);
133+
}
134+
entries[const DBusString('mpris:length')] =
135+
DBusInt64(track.duration * 1000000); // microseconds
136+
}
137+
138+
return DBusDict(
139+
DBusSignature('s'),
140+
DBusSignature('v'),
141+
entries.map((k, v) => MapEntry(k, DBusVariant(v))),
142+
);
143+
}
144+
145+
void emitPlaybackChanged(Map<String, DBusValue> changed) {
146+
emitSignal(
147+
_propertiesInterface,
148+
'PropertiesChanged',
149+
[
150+
const DBusString(_playerInterface),
151+
DBusDict.stringVariant(changed),
152+
DBusArray(DBusSignature('s'), []),
153+
],
154+
);
155+
}
156+
}
157+
158+
class MprisService {
159+
DBusClient? _client;
160+
_MprisObject? _object;
161+
162+
Future<void> initialize(AudioPlayerService audioPlayer) async {
163+
try {
164+
_client = DBusClient.session();
165+
_object = _MprisObject(audioPlayer);
166+
167+
await _client!.requestName('org.mpris.MediaPlayer2.torrential');
168+
await _client!.registerObject(_object!);
169+
170+
audioPlayer.playingStream.listen((playing) {
171+
_object?.emitPlaybackChanged({
172+
'PlaybackStatus': DBusString(playing ? 'Playing' : 'Paused'),
173+
});
174+
});
175+
176+
audioPlayer.trackStream.listen((_) {
177+
_object?.emitPlaybackChanged({
178+
'Metadata': _object!._buildMetadata(),
179+
});
180+
});
181+
182+
debugPrint('MPRIS service registered');
183+
} catch (e) {
184+
debugPrint('MPRIS init failed: $e');
185+
}
186+
}
187+
188+
Future<void> dispose() async {
189+
await _client?.close();
190+
}
191+
}

0 commit comments

Comments
 (0)