diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/core/services/audio_handler.dart b/lib/core/services/audio_handler.dart index ae064da..3764f2d 100644 --- a/lib/core/services/audio_handler.dart +++ b/lib/core/services/audio_handler.dart @@ -63,16 +63,14 @@ class BusicAudioHandler extends BaseAudioHandler with SeekHandler { mediaItem.add(null); return; } + mediaItem.add(MediaItem( + id: '${track.bvid}_${track.cid}', + title: track.title, + artist: track.artist, + artUri: track.coverUrl != null ? Uri.tryParse(track.coverUrl!) : null, + duration: duration ?? track.duration, + )); - mediaItem.add( - MediaItem( - id: '${track.bvid}_${track.cid}', - title: track.title, - artist: track.artist, - artUri: track.coverUrl != null ? Uri.tryParse(track.coverUrl!) : null, - duration: duration ?? track.duration, - ), - ); } /// Update the playback state shown in the media session. diff --git a/lib/core/services/mpris_service.dart b/lib/core/services/mpris_service.dart new file mode 100644 index 0000000..7f2fa6a --- /dev/null +++ b/lib/core/services/mpris_service.dart @@ -0,0 +1,185 @@ +import 'dart:io'; +import 'package:busic/features/player/domain/models/audio_track.dart'; +import 'package:busic/features/player/domain/models/play_mode.dart'; +import 'package:dbus/dbus.dart'; +import 'package:flutter/foundation.dart'; +import 'mpris_service_dbus.dart'; + +/// Service managing MPRIS (Media Player Remote Interfacing Specification) on Linux. +/// +/// It registers Busic as a media player on the system D-Bus, enabling integration +/// with system-level media widgets, shell extensions, and hardware media keys. +class MprisService { + DBusClient? _dbusClient; + MprisServiceDbus? _mprisObject; + + // Callbacks hooked into PlayerNotifier to bridge system actions to the player core. + VoidCallback? onPlay; + VoidCallback? onPause; + VoidCallback? onToggle; + VoidCallback? onNext; + VoidCallback? onPrevious; + ValueSetter? onSeek; + ValueSetter? setVolume; + ValueSetter? setMode; + + /// Initializes the MPRIS service and registers the D-Bus object on Linux. + /// + /// Binds the incoming control callbacks from [PlayerNotifier] and exposes + /// the media controller interface under `/org/mpris/MediaPlayer2`. + void init({ + required VoidCallback onPlay, + required VoidCallback onPause, + required VoidCallback onNext, + required VoidCallback onPrevious, + required ValueSetter onSeek, + required ValueSetter setVolume, + required ValueSetter setMode, + }) { + if (!Platform.isLinux) return; + + this.onPlay = () { + updatePlaybackStatus(true); + onPlay(); + }; + this.onPause = () { + updatePlaybackStatus(false); + onPause(); + }; + onToggle = () { + if (_mprisObject!.playbackState == 'Playing') { + this.onPause?.call(); + } else if (_mprisObject!.playbackState == 'Paused') { + this.onPlay?.call(); + } + }; + this.onNext = onNext; + this.onPrevious = onPrevious; + this.onSeek = (duration) { + updatePosition(duration); + onSeek(duration); + }; + this.setVolume = setVolume; + this.setMode = setMode; + try { + // 1. Establish connection to the D-Bus Session Bus. + _dbusClient = DBusClient.session(); + + // 2. Instantiate the generated DBusObject on the standard MPRIS object path. + _mprisObject = MprisServiceDbus( + path: DBusObjectPath('/org/mpris/MediaPlayer2')); + + // 3. Expose the object to external clients on D-Bus. + _dbusClient!.registerObject(_mprisObject!); + + // 4. Request the well-known name corresponding to this media player. + _dbusClient!.requestName('org.mpris.MediaPlayer2.busic').then((_) { + _bindSystemCommands(); + DBusBoolean bTrue = const DBusBoolean(true); + _mprisObject?.updatePlayerProperties({ + 'CanControl': bTrue, + 'CanGoNext': bTrue, + 'CanGoPrevious': bTrue, + 'CanPause': bTrue, + 'CanPlay': bTrue, + 'CanSeek': bTrue, + }); + }); + } catch (e) { + debugPrint('Failed to initialize MPRIS Service: $e'); + } + } + + /// Binds player control actions from D-Bus commands to our local handlers. + void _bindSystemCommands() { + if (_mprisObject == null) return; + _mprisObject!.onPlay = onPlay!; + _mprisObject!.onNext = onNext!; + _mprisObject!.onPause = onPause!; + _mprisObject!.onSeek = onSeek!; + _mprisObject!.onPrevious = onPrevious!; + _mprisObject!.onToggle = onToggle!; + _mprisObject!.setVolume = setVolume!; + _mprisObject!.setMode = setMode!; + } + + /// Maps and forwards current [AudioTrack] metadata updates to D-Bus properties. + void updateCurrentTrack(AudioTrack? track, {Duration? duration}) { + if (track == null) return; + updateMetadata( + title: track.title, + artist: track.artist, + artUrl: track.coverUrl, + duration: duration ?? track.duration, + ); + } + + /// Packs raw song metadata into the standard MPRIS Dict format (a{sv}) + /// and notifies the D-Bus daemon of property updates. + void updateMetadata({ + required String title, + required String artist, + String? album, + String? artUrl, + Duration? duration, + }) { + if (_mprisObject == null) return; + + // MPRIS specification expects metadata as a string-variant dictionary. + _mprisObject!.metadata = { + 'mpris:trackid': DBusObjectPath('/'), + 'mpris:length': DBusInt64(duration?.inMicroseconds ?? 0), // MPRIS uses microseconds for duration. + 'xesam:title': DBusString(title), + 'xesam:artist': DBusArray.string([artist]), + 'xesam:album': DBusString(album ?? ''), + 'mpris:artUrl': DBusString(artUrl ?? ''), + }; + + _mprisObject?.updatePlayerProperties({ + 'Metadata': DBusDict.stringVariant(_mprisObject!.metadata) + }); + } + + /// Updates the player's playback state (Playing / Paused / Stopped) on D-Bus. + void updatePlaybackStatus(bool isPlaying) { + if (_mprisObject == null) return; + String status = isPlaying ? 'Playing' : 'Paused'; + _mprisObject!.playbackState = status; + _mprisObject?.updatePlayerProperties({ + 'PlaybackStatus': DBusString(status) + }); + } + + /// Synchronizes the current playback position (in microseconds) with D-Bus. + void updatePosition(Duration position) { + if (_mprisObject == null) return; + _mprisObject!.position = position.inMicroseconds; + } + /// Synchronizes the player's loop status (enum PlayMode)on D-Bus. + void updateLoopStatus(PlayMode mode) { + String status = switch(mode){ + PlayMode.sequential => 'None', + PlayMode.repeatOne=> 'Track', + PlayMode.shuffle => 'Shuffle', + PlayMode.repeatAll => 'Playlist' + }; + if (status == 'Shuffle') { + _mprisObject?.shuffle = true; + _mprisObject?.updatePlayerProperties({'Shuffle' : const DBusBoolean(true)}); + } else { + _mprisObject?.loopStatus = status; + _mprisObject?.updatePlayerProperties({'LoopStatus': DBusString(status)}); + } + } + /// Synchronizes the player's volume (0.0 to 1.0) with D-Bus. + void updateVolume(double volume) { + _mprisObject?.volume = volume > 1.0 ? 1.0 : volume < 0.0 ? 0.0 : volume; + _mprisObject?.updatePlayerProperties({'Volume': DBusDouble(volume)}); + } + /// Closes active D-Bus connections and frees resources when stopping. + void dispose() { + _dbusClient?.close(); + _dbusClient = null; + _mprisObject = null; + } +} diff --git a/lib/core/services/mpris_service_dbus.dart b/lib/core/services/mpris_service_dbus.dart new file mode 100644 index 0000000..286c6eb --- /dev/null +++ b/lib/core/services/mpris_service_dbus.dart @@ -0,0 +1,641 @@ +// This file was generated using the following command and may be overwritten. +// dart-dbus generate-object ../mpris.xml + +import 'dart:async'; + +import 'package:busic/features/player/domain/models/play_mode.dart'; +import 'package:dbus/dbus.dart'; +import 'package:flutter/foundation.dart'; + +/// Low-level D-Bus object implementing MPRIS-specific interfaces. +/// +/// Implements standard D-Bus properties and handlers for `org.mpris.MediaPlayer2` +/// and `org.mpris.MediaPlayer2.Player` to interact with Linux desktop environments. +class MprisServiceDbus extends DBusObject { + // --- Standard MPRIS Player properties --- + late Map metadata = {}; + late String playbackState = 'Stopped'; + late String loopStatus = 'None'; + late double maximumRate = 1.0; + late double minimumRate = 1.0; + late int position = 0; // Stored in microseconds. + late double rate = 1.0; + late double volume = 1.0; + late bool shuffle = false; + late bool canQuit, canRaise = true; + late bool hasTrackList = false; + late bool canControl, canGoNext, canGoPrevious, canPause, canPlay, canSeek; + + // --- External control callbacks injected by the service coordinator --- + VoidCallback? onPlay; + VoidCallback? onPause; + VoidCallback? onToggle; + VoidCallback? onNext; + VoidCallback? onPrevious; + ValueSetter? onSeek; + ValueSetter? setVolume; + ValueSetter? setMode; + + /// Broadcasts property changes over the D-Bus bus to keep system clients in sync. + void updatePlayerProperties(Map properties) { + emitPropertiesChanged( + 'org.mpris.MediaPlayer2.Player', + changedProperties: properties, + invalidatedProperties: [], + ).catchError((err) { + if (kDebugMode) { + print(err); + } + }); + } + + /// Creates a new object to expose on [path]. + /// Sets up static capability properties indicating what features the player supports. + + MprisServiceDbus({DBusObjectPath path = const DBusObjectPath.unchecked('/')}) + : super(path) { + canQuit = true; + canRaise = true; + hasTrackList = false; + canControl = true; + canGoNext = true; + canGoPrevious = true; + canPause = true; + canPlay = true; + canSeek = true; + } + + /// Gets value of property org.mpris.MediaPlayer2.CanQuit + Future getCanQuit() async { + return DBusGetPropertyResponse(DBusBoolean(canQuit)); + } + + /// Gets value of property org.mpris.MediaPlayer2.CanRaise + Future getCanRaise() async { + return DBusGetPropertyResponse(DBusBoolean(canRaise)); + } + + /// Gets value of property org.mpris.MediaPlayer2.HasTrackList + Future getHasTrackList() async { + return DBusGetPropertyResponse(DBusBoolean(hasTrackList)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Identity + Future getIdentity() async { + return DBusGetPropertyResponse(const DBusString('Busic')); + } + + /// Gets value of property org.mpris.MediaPlayer2.DesktopEntry + Future getDesktopEntry() async { + return DBusGetPropertyResponse(const DBusString('Busic.desktop')); + } + + /// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes + Future getSupportedUriSchemes() async { + return DBusGetPropertyResponse(DBusArray.variant([])); + } + + /// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes + Future getSupportedMimeTypes() async { + return DBusGetPropertyResponse(DBusArray.variant([])); + } + + /// Implementation of org.mpris.MediaPlayer2.Raise() + Future doRaise() async { + return DBusMethodSuccessResponse([]); + } + + /// Implementation of org.mpris.MediaPlayer2.Quit() + Future doQuit() async { + return DBusMethodSuccessResponse([]); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus + Future getPlaybackStatus() async { + return DBusGetPropertyResponse(DBusString(playbackState)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus + Future getLoopStatus() async { + return DBusGetPropertyResponse(DBusString(loopStatus)); + } + + /// Handles incoming requests to change LoopStatus from external panels. + /// + /// Translates standard MPRIS loop states ("Playlist", "Track", "None") + /// into application-specific [PlayMode] enum variants. + Future setLoopStatus(String value) async { + if (loopStatus == value) return DBusMethodSuccessResponse(); + + loopStatus = value; + updatePlayerProperties({'LoopStatus': DBusString(value)}); + + switch(value) { + case 'Playlist': + setMode?.call(PlayMode.repeatAll); + case 'Track': + setMode?.call(PlayMode.repeatOne); + case 'None': + setMode?.call(PlayMode.sequential); + } + if (shuffle) { + await setShuffle(false); + } + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Rate + Future getRate() async { + return DBusGetPropertyResponse(DBusDouble(rate)); + } + + /// Sets property org.mpris.MediaPlayer2.Player.Rate + Future setRate(double value) async { + rate = value; + updatePlayerProperties({'Rate': DBusDouble(value)}); + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle + Future getShuffle() async { + return DBusGetPropertyResponse(DBusBoolean(shuffle)); + } + + /// Handles incoming requests to toggle shuffle state from external panels. + Future setShuffle(bool value) async { + if (shuffle == value) return DBusMethodSuccessResponse(); + + shuffle = value; + updatePlayerProperties({'Shuffle': DBusBoolean(value)}); + + if (shuffle) { + setMode?.call(PlayMode.shuffle); + } else { + await setLoopStatus(loopStatus); + } + return DBusMethodSuccessResponse(); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata + Future getMetadata() async { + return DBusGetPropertyResponse(DBusDict.stringVariant(metadata)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Volume + Future getVolume() async { + return DBusGetPropertyResponse(DBusDouble(volume)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.Position + Future getPosition() async { + return DBusGetPropertyResponse(DBusInt64(position)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate + Future getMinimumRate() async { + return DBusGetPropertyResponse(DBusDouble(minimumRate)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate + Future getMaximumRate() async { + return DBusGetPropertyResponse(DBusDouble(maximumRate)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext + Future getCanGoNext() async { + return DBusGetPropertyResponse(DBusBoolean(canGoNext)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious + Future getCanGoPrevious() async { + return DBusGetPropertyResponse(DBusBoolean(canGoPrevious)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay + Future getCanPlay() async { + return DBusGetPropertyResponse(DBusBoolean(canPlay)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanPause + Future getCanPause() async { + return DBusGetPropertyResponse(DBusBoolean(canPause)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek + Future getCanSeek() async { + return DBusGetPropertyResponse(DBusBoolean(canSeek)); + } + + /// Gets value of property org.mpris.MediaPlayer2.Player.CanControl + Future getCanControl() async { + return DBusGetPropertyResponse(DBusBoolean(canControl)); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Next() + Future doNext() async { + onNext?.call(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Previous() + Future doPrevious() async { + onPrevious?.call(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Pause() + Future doPause() async { + onPause?.call(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() + Future doPlayPause() async { + onToggle?.call(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Stop() + Future doStop() async { + onPause?.call(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Play() + Future doPlay() async { + onPlay?.call(); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.Seek() + /// + /// MPRIS Seek uses millisecond relative offsets for forward/backward steps. + Future doSeek(int offset) async { + onSeek?.call(Duration(milliseconds: offset)); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.SetPosition() + /// + /// MPRIS SetPosition targets an absolute position within a given track (in microseconds). + Future doSetPosition( + DBusObjectPath trackId, int position) async { + this.position = position; + onSeek?.call(Duration(microseconds: position)); + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.OpenUri() + Future doOpenUri(String uri) async { + return DBusMethodSuccessResponse(); + } + + /// Implementation of org.mpris.MediaPlayer2.Player.SetVolume() + Future doSetVolume(double volume) async { + // 1. 只有当音量确实发生改变时才执行,避免高频冗余触发 + if ((this.volume - volume).abs() < 0.01) { + return DBusMethodSuccessResponse(); + } + if (volume < 0.0 || volume > 1.0) { + return DBusMethodErrorResponse.failed('Volume in 0.0 to 1.0'); + } + + this.volume = volume; + setVolume?.call(volume); // 安全调用 + updatePlayerProperties({'Volume': DBusDouble(volume)}); + return DBusMethodSuccessResponse(); + } + + /// Emits signal org.mpris.MediaPlayer2.Player.Seeked + Future emitSeeked(int position) async { + await emitSignal( + 'org.mpris.MediaPlayer2.Player', 'Seeked', [DBusInt64(position)]); + } + + // --- Auto-generated D-Bus Introspection and Dispatch boilerplate --- + + @override + List introspect() { + return [ + DBusIntrospectInterface('org.mpris.MediaPlayer2', methods: [ + DBusIntrospectMethod('Raise'), + DBusIntrospectMethod('Quit') + ], properties: [ + DBusIntrospectProperty('CanQuit', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanRaise', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('HasTrackList', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('Identity', DBusSignature('s'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('DesktopEntry', DBusSignature('s'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('SupportedUriSchemes', DBusSignature('as'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('SupportedMimeTypes', DBusSignature('as'), + access: DBusPropertyAccess.read) + ]), + DBusIntrospectInterface('org.mpris.MediaPlayer2.Player', methods: [ + DBusIntrospectMethod('Next'), + DBusIntrospectMethod('Previous'), + DBusIntrospectMethod('Pause'), + DBusIntrospectMethod('PlayPause'), + DBusIntrospectMethod('Stop'), + DBusIntrospectMethod('Play'), + DBusIntrospectMethod('Seek', args: [ + DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, + name: 'Offset') + ]), + DBusIntrospectMethod('SetPosition', args: [ + DBusIntrospectArgument(DBusSignature('o'), DBusArgumentDirection.in_, + name: 'TrackId'), + DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, + name: 'Position') + ]), + DBusIntrospectMethod('OpenUri', args: [ + DBusIntrospectArgument(DBusSignature('s'), DBusArgumentDirection.in_, + name: 'Uri') + ]), + DBusIntrospectMethod('SetVolume', args: [ + DBusIntrospectArgument(DBusSignature('d'), DBusArgumentDirection.in_, + name: 'volume') + ]) + ], signals: [ + DBusIntrospectSignal('Seeked', args: [ + DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.out, + name: 'Position') + ]) + ], properties: [ + DBusIntrospectProperty('PlaybackStatus', DBusSignature('s'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('LoopStatus', DBusSignature('s'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Rate', DBusSignature('d'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Shuffle', DBusSignature('b'), + access: DBusPropertyAccess.readwrite), + DBusIntrospectProperty('Metadata', DBusSignature('a{sv}'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('Volume', DBusSignature('d'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('Position', DBusSignature('x'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('MinimumRate', DBusSignature('d'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('MaximumRate', DBusSignature('d'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanGoNext', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanGoPrevious', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanPlay', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanPause', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanSeek', DBusSignature('b'), + access: DBusPropertyAccess.read), + DBusIntrospectProperty('CanControl', DBusSignature('b'), + access: DBusPropertyAccess.read) + ]) + ]; + } + + @override + Future handleMethodCall(DBusMethodCall methodCall) async { + if (methodCall.interface == 'org.mpris.MediaPlayer2') { + if (methodCall.name == 'Raise') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doRaise(); + } else if (methodCall.name == 'Quit') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doQuit(); + } else { + return DBusMethodErrorResponse.unknownMethod(); + } + } else if (methodCall.interface == 'org.mpris.MediaPlayer2.Player') { + if (methodCall.name == 'Next') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doNext(); + } else if (methodCall.name == 'Previous') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPrevious(); + } else if (methodCall.name == 'Pause') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPause(); + } else if (methodCall.name == 'PlayPause') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPlayPause(); + } else if (methodCall.name == 'Stop') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doStop(); + } else if (methodCall.name == 'Play') { + if (methodCall.values.isNotEmpty) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doPlay(); + } else if (methodCall.name == 'Seek') { + if (methodCall.signature != DBusSignature('x')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doSeek(methodCall.values[0].asInt64()); + } else if (methodCall.name == 'SetPosition') { + if (methodCall.signature != DBusSignature('ox')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doSetPosition(methodCall.values[0].asObjectPath(), + methodCall.values[1].asInt64()); + } else if (methodCall.name == 'OpenUri') { + if (methodCall.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doOpenUri(methodCall.values[0].asString()); + } else if (methodCall.name == 'SetVolume') { + if (methodCall.signature != DBusSignature('d')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return doSetVolume(methodCall.values[0].asDouble()); + } else { + return DBusMethodErrorResponse.unknownMethod(); + } + } else { + return DBusMethodErrorResponse.unknownInterface(); + } + } + + @override + Future getProperty(String interface, String name) async { + if (interface == 'org.mpris.MediaPlayer2') { + if (name == 'CanQuit') { + return getCanQuit(); + } else if (name == 'CanRaise') { + return getCanRaise(); + } else if (name == 'HasTrackList') { + return getHasTrackList(); + } else if (name == 'Identity') { + return getIdentity(); + } else if (name == 'DesktopEntry') { + return getDesktopEntry(); + } else if (name == 'SupportedUriSchemes') { + return getSupportedUriSchemes(); + } else if (name == 'SupportedMimeTypes') { + return getSupportedMimeTypes(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else if (interface == 'org.mpris.MediaPlayer2.Player') { + if (name == 'PlaybackStatus') { + return getPlaybackStatus(); + } else if (name == 'LoopStatus') { + return getLoopStatus(); + } else if (name == 'Rate') { + return getRate(); + } else if (name == 'Shuffle') { + return getShuffle(); + } else if (name == 'Metadata') { + return getMetadata(); + } else if (name == 'Volume') { + return getVolume(); + } else if (name == 'Position') { + return getPosition(); + } else if (name == 'MinimumRate') { + return getMinimumRate(); + } else if (name == 'MaximumRate') { + return getMaximumRate(); + } else if (name == 'CanGoNext') { + return getCanGoNext(); + } else if (name == 'CanGoPrevious') { + return getCanGoPrevious(); + } else if (name == 'CanPlay') { + return getCanPlay(); + } else if (name == 'CanPause') { + return getCanPause(); + } else if (name == 'CanSeek') { + return getCanSeek(); + } else if (name == 'CanControl') { + return getCanControl(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future setProperty( + String interface, String name, DBusValue value) async { + if (interface == 'org.mpris.MediaPlayer2') { + if (name == 'CanQuit') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanRaise') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'HasTrackList') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'Identity') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'DesktopEntry') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'SupportedUriSchemes') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'SupportedMimeTypes') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else if (interface == 'org.mpris.MediaPlayer2.Player') { + if (name == 'PlaybackStatus') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'LoopStatus') { + if (value.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setLoopStatus(value.asString()); + } else if (name == 'Rate') { + if (value.signature != DBusSignature('d')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setRate(value.asDouble()); + } else if (name == 'Shuffle') { + if (value.signature != DBusSignature('b')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return setShuffle(value.asBoolean()); + } else if (name == 'Metadata') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'Volume') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'Position') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'MinimumRate') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'MaximumRate') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanGoNext') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanGoPrevious') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanPlay') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanPause') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanSeek') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else if (name == 'CanControl') { + return DBusMethodErrorResponse.propertyReadOnly(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future getAllProperties(String interface) async { + var properties = {}; + if (interface == 'org.mpris.MediaPlayer2') { + properties['CanQuit'] = (await getCanQuit()).returnValues[0]; + properties['CanRaise'] = (await getCanRaise()).returnValues[0]; + properties['HasTrackList'] = (await getHasTrackList()).returnValues[0]; + properties['Identity'] = (await getIdentity()).returnValues[0]; + properties['DesktopEntry'] = (await getDesktopEntry()).returnValues[0]; + properties['SupportedUriSchemes'] = + (await getSupportedUriSchemes()).returnValues[0]; + properties['SupportedMimeTypes'] = + (await getSupportedMimeTypes()).returnValues[0]; + } else if (interface == 'org.mpris.MediaPlayer2.Player') { + properties['PlaybackStatus'] = + (await getPlaybackStatus()).returnValues[0]; + properties['LoopStatus'] = (await getLoopStatus()).returnValues[0]; + properties['Rate'] = (await getRate()).returnValues[0]; + properties['Shuffle'] = (await getShuffle()).returnValues[0]; + properties['Metadata'] = (await getMetadata()).returnValues[0]; + properties['Volume'] = (await getVolume()).returnValues[0]; + properties['Position'] = (await getPosition()).returnValues[0]; + properties['MinimumRate'] = (await getMinimumRate()).returnValues[0]; + properties['MaximumRate'] = (await getMaximumRate()).returnValues[0]; + properties['CanGoNext'] = (await getCanGoNext()).returnValues[0]; + properties['CanGoPrevious'] = (await getCanGoPrevious()).returnValues[0]; + properties['CanPlay'] = (await getCanPlay()).returnValues[0]; + properties['CanPause'] = (await getCanPause()).returnValues[0]; + properties['CanSeek'] = (await getCanSeek()).returnValues[0]; + properties['CanControl'] = (await getCanControl()).returnValues[0]; + } + return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); + } +} diff --git a/lib/features/player/application/player_notifier.dart b/lib/features/player/application/player_notifier.dart index 70b1211..bfef659 100644 --- a/lib/features/player/application/player_notifier.dart +++ b/lib/features/player/application/player_notifier.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:busic/core/services/mpris_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -47,6 +48,7 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { late PlayerRepository _repository; late ParseRepository _parseRepository; late BusicAudioHandler _audioHandler; + late MprisService _mprisService; late AppDatabase _db; final List _subscriptions = []; DateTime _lastPersist = DateTime.fromMillisecondsSinceEpoch(0); @@ -67,12 +69,10 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { _parseRepository = ref.read(playerParseRepositoryProvider); _audioHandler = ref.read(audioHandlerProvider); _db = ref.read(databaseProvider); - // Listen for download completions and refresh queue localPaths. ref.listen(downloadChangeSignalProvider, (_, __) { _refreshQueueLocalPaths(); }); - // Connect media button callbacks (lock screen / notification controls) _audioHandler.onPlay = () => resume(); _audioHandler.onPause = () => pause(); @@ -81,6 +81,18 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { _audioHandler.onSeek = (pos) => seekTo(pos); _audioHandler.onStop = () => pause(); + if (Platform.isLinux) { + _mprisService = MprisService(); + _mprisService.init( + onPlay: () => resume(), + onPause: () => pause(), + onNext: () => next(), + onPrevious: () => previous(), + onSeek: (pos) => seekTo(pos), + setVolume: (volume) => setVolume(volume), + setMode: (mode) => setMode(mode) + ); + } // Listen to player streams _subscriptions.add( _repository.positionStream.listen((pos) { @@ -90,6 +102,10 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { playing: state.isPlaying, position: pos, ); + if (Platform.isLinux) { + _mprisService.updatePlaybackStatus(state.isPlaying); + _mprisService.updatePosition(pos); + } // Throttle persist to once every 5 seconds final now = DateTime.now(); if (now.difference(_lastPersist).inSeconds >= 5) { @@ -103,6 +119,9 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { state = state.copyWith(duration: dur); // Update media session with the correct duration _audioHandler.setCurrentTrack(state.currentTrack, duration: dur); + if (Platform.isLinux) { + _mprisService.updateCurrentTrack(state.currentTrack, duration: dur); + } }), ); _subscriptions.add( @@ -112,6 +131,10 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { playing: playing, position: state.position, ); + if (Platform.isLinux) { + _mprisService.updatePlaybackStatus(playing); + _mprisService.updatePosition(state.position); + } }), ); _subscriptions.add( @@ -125,6 +148,7 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { sub.cancel(); } _repository.dispose(); + _mprisService.dispose(); }); // Restore last session asynchronously @@ -540,12 +564,18 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { /// Set the playback mode (sequential, repeat, shuffle). void setMode(PlayMode mode) { state = state.copyWith(playMode: mode); + if (Platform.isLinux) { + _mprisService.updateLoopStatus(mode); + } } /// Set the volume level (0.0 to 1.0). Future setVolume(double volume) async { await _repository.setVolume(volume); state = state.copyWith(volume: volume); + if (Platform.isLinux) { + _mprisService.updateVolume(volume); + } } /// Update the songId of the current track and its queue entry. diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ce977ef..6fb181a 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -63,7 +63,7 @@ import 'app_localizations_zh.dart'; /// property. abstract class AppLocalizations { AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -86,11 +86,11 @@ abstract class AppLocalizations { /// of delegates is preferred or required. static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ @@ -2214,9 +2214,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { } throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', - ); + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); } diff --git a/pubspec.yaml b/pubspec.yaml index 1cc0a82..0333e57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: busic description: A modern cross-platform music player powered by Bilibili. -publish_to: 'none' +publish_to: "none" version: 0.4.3+18 environment: @@ -41,6 +41,9 @@ dependencies: media_kit_libs_macos_audio: ^1.0.0 media_kit_libs_android_audio: ^1.3.6 + #Dbus Mpris + dbus: ^0.7.14 + # Desktop Window Management window_manager: ^0.3.9 @@ -98,9 +101,9 @@ flutter_launcher_icons: generate: true linux: generate: true - image_path: 'assets/images/app_icon.png' - adaptive_icon_foreground: 'assets/images/app_icon.png' - adaptive_icon_background: '#FFFFFF' + image_path: "assets/images/app_icon.png" + adaptive_icon_foreground: "assets/images/app_icon.png" + adaptive_icon_background: "#FFFFFF" remove_alpha_ios: true flutter: