From 5eeebd96684b106c453de5dd30dce2482caedebf Mon Sep 17 00:00:00 2001 From: eon-ic Date: Wed, 15 Jul 2026 00:06:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(linux):=20=E6=94=AF=E6=8C=81=20MPRIS?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E7=BA=A7=E5=AA=92=E4=BD=93=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MprisService,支持在 Linux 系统通知栏和快捷键中控制播放器[cite: 1, 2] - 实时同步歌曲元数据、播放状态和音量[cite: 1, 2],并深度优化了 D-Bus 传输性能 - 完善了资源销毁与防崩溃机制[cite: 2, 3],保障多平台运行稳定[cite: 1] --- devtools_options.yaml | 3 + l10n.yaml | 1 - lib/core/services/audio_handler.dart | 2 +- lib/core/services/mpris_service.dart | 159 +++++ lib/core/services/mpris_service_dbus.dart | 644 ++++++++++++++++++ .../player/application/player_notifier.dart | 28 +- lib/l10n/generated/app_localizations.dart | 32 +- lib/l10n/generated/app_localizations_en.dart | 78 ++- lib/l10n/generated/app_localizations_zh.dart | 12 +- pubspec.yaml | 15 +- 10 files changed, 920 insertions(+), 54 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/core/services/mpris_service.dart create mode 100644 lib/core/services/mpris_service_dbus.dart 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/l10n.yaml b/l10n.yaml index 4b7fc04..0c66187 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,4 +3,3 @@ template-arb-file: app_en.arb output-localization-file: app_localizations.dart output-class: AppLocalizations output-dir: lib/l10n/generated -synthetic-package: false diff --git a/lib/core/services/audio_handler.dart b/lib/core/services/audio_handler.dart index ebc6e75..d5433eb 100644 --- a/lib/core/services/audio_handler.dart +++ b/lib/core/services/audio_handler.dart @@ -63,7 +63,6 @@ class BusicAudioHandler extends BaseAudioHandler with SeekHandler { mediaItem.add(null); return; } - mediaItem.add(MediaItem( id: '${track.bvid}_${track.cid}', title: track.title, @@ -71,6 +70,7 @@ class BusicAudioHandler extends BaseAudioHandler with SeekHandler { 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..fb7489a --- /dev/null +++ b/lib/core/services/mpris_service.dart @@ -0,0 +1,159 @@ +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'); + _bindSystemCommands(); + _mprisObject!.init(); + } 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. + Future updateMetadata({ + required String title, + required String artist, + String? album, + String? artUrl, + Duration? duration, + }) async { + 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 ?? ''), + }; + + await _mprisObject!.updatePlayerProperties({ + 'Metadata': DBusDict.stringVariant(_mprisObject!.metadata) + }); + } + + /// Updates the player's playback state (Playing / Paused / Stopped) on D-Bus. + Future updatePlaybackStatus(bool isPlaying) async { + if (_mprisObject == null) return; + String status = isPlaying ? 'Playing' : 'Paused'; + _mprisObject!.playbackState = status; + await _mprisObject!.updatePlayerProperties({ + 'PlaybackStatus': DBusString(status) + }); + } + + /// Synchronizes the current playback position (in microseconds) with D-Bus. + Future updatePosition(Duration position) async { + if (_mprisObject == null) return; + _mprisObject!.position = position.inMicroseconds; + // await _mprisObject!.updatePlayerProperties({ + // 'Position': DBusInt64(_mprisObject!.position) + // }); + } + + /// 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..0f11f15 --- /dev/null +++ b/lib/core/services/mpris_service_dbus.dart @@ -0,0 +1,644 @@ +// This file was generated using the following command and may be overwritten. +// dart-dbus generate-object ../mpris.xml + +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 = ''; + 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; + + /// Sets up static capability properties indicating what features the player supports. + init() { + canQuit = true; + canRaise = true; + hasTrackList = false; + canControl = true; + canGoNext = true; + canGoPrevious = true; + canPause = true; + canPlay = true; + canSeek = true; + + DBusBoolean bTrue = const DBusBoolean(true); + updatePlayerProperties({ + 'CanControl': bTrue, + 'CanGoNext': bTrue, + 'CanGoPrevious': bTrue, + 'CanPause': bTrue, + 'CanPlay': bTrue, + 'CanSeek': bTrue, + }); + } + + // --- 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. + Future updatePlayerProperties(Map properties) async { + await emitPropertiesChanged( + 'org.mpris.MediaPlayer2.Player', // Target interface holding these properties. + changedProperties: properties, + invalidatedProperties: [], + ); + } + + /// Creates a new object to expose on [path]. + MprisServiceDbus({DBusObjectPath path = const DBusObjectPath.unchecked('/')}) + : super(path); + + /// 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; + await 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; + await 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(); + } + + this.volume = volume; + setVolume?.call(volume); // 安全调用 + await 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 88ec15c..e172fdf 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:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/api/bili_dio.dart'; @@ -46,6 +47,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); @@ -66,12 +68,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(); @@ -80,6 +80,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) { @@ -89,6 +101,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) { @@ -102,6 +118,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( @@ -111,6 +130,10 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { playing: playing, position: state.position, ); + if (Platform.isLinux) { + _mprisService.updatePlaybackStatus(playing); + _mprisService.updatePosition(state.position); + } }), ); _subscriptions.add( @@ -124,6 +147,7 @@ class PlayerNotifier extends _$PlayerNotifier with PlayerStatePersistence { sub.cancel(); } _repository.dispose(); + _mprisService.dispose(); }); // Restore last session asynchronously diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index b046c07..60f814d 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -62,7 +62,8 @@ import 'app_localizations_zh.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -70,7 +71,8 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations); } - static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -82,7 +84,8 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = >[ + static const List> localizationsDelegates = + >[ delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, @@ -2184,7 +2187,8 @@ abstract class AppLocalizations { String get lyricsError; } -class _AppLocalizationsDelegate extends LocalizationsDelegate { +class _AppLocalizationsDelegate + extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -2193,25 +2197,25 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate } @override - bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { - - // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': return AppLocalizationsEn(); - case 'zh': return AppLocalizationsZh(); + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); } 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/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f829490..513da58 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -60,7 +60,8 @@ class AppLocalizationsEn extends AppLocalizations { String get webLoginTitle => 'Bilibili Web Login'; @override - String get webLoginDesc => 'Log in on the Bilibili page below. BuSic only reads cookies from this in-app window.'; + String get webLoginDesc => + 'Log in on the Bilibili page below. BuSic only reads cookies from this in-app window.'; @override String get webLoginPreparing => 'Preparing web login...'; @@ -75,49 +76,58 @@ class AppLocalizationsEn extends AppLocalizations { String get webLoginCookieMissing => 'Login cookies were not found yet'; @override - String get webLoginUnsupportedTitle => 'Web login is unavailable on this platform'; + String get webLoginUnsupportedTitle => + 'Web login is unavailable on this platform'; @override - String get webLoginUnsupportedDesc => 'Use QR login or manual Cookie login on this device.'; + String get webLoginUnsupportedDesc => + 'Use QR login or manual Cookie login on this device.'; @override String get webLoginBrowserMissingTitle => 'No supported browser found'; @override - String get webLoginBrowserMissingDesc => 'Install Chrome, Chromium, Edge, Brave, Vivaldi, or Firefox to use Linux web login, or use QR login / manual Cookie login.'; + String get webLoginBrowserMissingDesc => + 'Install Chrome, Chromium, Edge, Brave, Vivaldi, or Firefox to use Linux web login, or use QR login / manual Cookie login.'; @override String get webLoginWebView2MissingTitle => 'WebView2 Runtime is missing'; @override - String get webLoginWebView2MissingDesc => 'This Windows device does not have Microsoft Edge WebView2 Runtime available. Install it, or use QR login or manual Cookie login.'; + String get webLoginWebView2MissingDesc => + 'This Windows device does not have Microsoft Edge WebView2 Runtime available. Install it, or use QR login or manual Cookie login.'; @override String get webLoginInitFailedTitle => 'Web login failed to start'; @override - String get webLoginInitFailedDesc => 'The in-app WebView could not be started. Check that WebView2 Runtime is available, or use QR login or manual Cookie login.'; + String get webLoginInitFailedDesc => + 'The in-app WebView could not be started. Check that WebView2 Runtime is available, or use QR login or manual Cookie login.'; @override - String get webLoginPageLoadFailed => 'The login page failed to load. Check your network and try again.'; + String get webLoginPageLoadFailed => + 'The login page failed to load. Check your network and try again.'; @override String get webLoginLinuxTitle => 'Linux Web Login'; @override - String get webLoginLinuxDesc => 'BuSic will open a temporary browser window and only read cookies from that isolated login session.'; + String get webLoginLinuxDesc => + 'BuSic will open a temporary browser window and only read cookies from that isolated login session.'; @override String get webLoginLinuxOpenBrowser => 'Open login window'; @override - String get webLoginLinuxWaiting => 'Complete Bilibili login in the browser window, then return here to verify.'; + String get webLoginLinuxWaiting => + 'Complete Bilibili login in the browser window, then return here to verify.'; @override String get webLoginLinuxCancel => 'Cancel'; @override - String get webLoginLinuxStartFailed => 'The temporary browser login could not start. Use QR login or manual Cookie login.'; + String get webLoginLinuxStartFailed => + 'The temporary browser login could not start. Use QR login or manual Cookie login.'; @override String webLoginFailedWithError(String error) { @@ -128,7 +138,8 @@ class AppLocalizationsEn extends AppLocalizations { String get cookieLoginTitle => 'Cookie Login'; @override - String get cookieLoginDesc => 'Paste the Bilibili cookies from your browser. Find them on bilibili.com in DevTools > Application > Cookies.'; + String get cookieLoginDesc => + 'Paste the Bilibili cookies from your browser. Find them on bilibili.com in DevTools > Application > Cookies.'; @override String get cookieRequired => 'Fill in all Cookie fields'; @@ -295,10 +306,12 @@ class AppLocalizationsEn extends AppLocalizations { String get noPlaylists => 'No playlists yet'; @override - String get playlistHomeSubtitle => 'Shape your Bilibili finds into a focused listening library.'; + String get playlistHomeSubtitle => + 'Shape your Bilibili finds into a focused listening library.'; @override - String get noPlaylistsHint => 'Create a playlist or import one to start building your library.'; + String get noPlaylistsHint => + 'Create a playlist or import one to start building your library.'; @override String get unknownArtist => 'Unknown Artist'; @@ -328,7 +341,8 @@ class AppLocalizationsEn extends AppLocalizations { String get deleteDownload => 'Delete Download'; @override - String get deleteDownloadConfirm => 'Are you sure you want to delete the downloaded file?'; + String get deleteDownloadConfirm => + 'Are you sure you want to delete the downloaded file?'; @override String get activeDownloads => 'Active Downloads'; @@ -453,7 +467,8 @@ class AppLocalizationsEn extends AppLocalizations { String get overwriteConfirmTitle => 'Confirm Overwrite'; @override - String get overwriteConfirmMessage => 'Overwriting will clear all existing playlists and associations. This cannot be undone. Continue?'; + String get overwriteConfirmMessage => + 'Overwriting will clear all existing playlists and associations. This cannot be undone. Continue?'; @override String get exportSuccess => 'Export successful'; @@ -617,7 +632,8 @@ class AppLocalizationsEn extends AppLocalizations { String get importingPlaylist => 'Importing playlist...'; @override - String get searchPageSubtitle => 'Search Bilibili or paste a BV link without leaving your music flow.'; + String get searchPageSubtitle => + 'Search Bilibili or paste a BV link without leaving your music flow.'; @override String get searchCommandTitle => 'Search or parse'; @@ -635,13 +651,15 @@ class AppLocalizationsEn extends AppLocalizations { String get searchEmptyTitle => 'Start with a keyword or BV link'; @override - String get searchEmptySubtitle => 'Results, parsed video details, page selection and comments will appear here.'; + String get searchEmptySubtitle => + 'Results, parsed video details, page selection and comments will appear here.'; @override String get searchNoResultsTitle => 'No results found'; @override - String get searchNoResultsSubtitle => 'Try another keyword or paste a BV link.'; + String get searchNoResultsSubtitle => + 'Try another keyword or paste a BV link.'; @override String get searchLoadingMore => 'Loading more...'; @@ -719,7 +737,8 @@ class AppLocalizationsEn extends AppLocalizations { String get forceUpdateTitle => 'Required update'; @override - String get forceUpdateMessage => 'This version is no longer supported. Please update to continue using BuSic.'; + String get forceUpdateMessage => + 'This version is no longer supported. Please update to continue using BuSic.'; @override String get downloadLatestVersion => 'Download latest version'; @@ -776,7 +795,8 @@ class AppLocalizationsEn extends AppLocalizations { String get retryDownload => 'Retry download'; @override - String get linuxReadOnlyInstallDir => 'Linux system-installed apps cannot auto-update. Please download the new version manually.'; + String get linuxReadOnlyInstallDir => + 'Linux system-installed apps cannot auto-update. Please download the new version manually.'; @override String get openDownloadPage => 'Open download page'; @@ -809,13 +829,15 @@ class AppLocalizationsEn extends AppLocalizations { String get createPlaylistManual => 'Create Custom Playlist'; @override - String get createPlaylistManualDesc => 'Enter a name to create an empty playlist'; + String get createPlaylistManualDesc => + 'Enter a name to create an empty playlist'; @override String get importFromBiliFav => 'Import from Bilibili Favorites'; @override - String get importFromBiliFavDesc => 'Sign in to import from your favorite folders'; + String get importFromBiliFavDesc => + 'Sign in to import from your favorite folders'; @override String get myFavFolders => 'My Folders'; @@ -854,7 +876,8 @@ class AppLocalizationsEn extends AppLocalizations { String get pleaseLoginFirst => 'Please sign in to Bilibili first'; @override - String get biliSessionInvalid => 'Bilibili login has expired. Please sign in again.'; + String get biliSessionInvalid => + 'Bilibili login has expired. Please sign in again.'; @override String biliFavSongCount(int count) { @@ -943,7 +966,8 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get videoNoReprint => 'Reposting without the creator\'s permission is prohibited'; + String get videoNoReprint => + 'Reposting without the creator\'s permission is prohibited'; @override String get videoDescriptionEmpty => 'No description'; @@ -978,7 +1002,8 @@ class AppLocalizationsEn extends AppLocalizations { String get videoShare => 'Share'; @override - String get videoCoinsAlreadyMaxed => 'You have already given the maximum coins'; + String get videoCoinsAlreadyMaxed => + 'You have already given the maximum coins'; @override String videoCoinAdded(int count) { @@ -1036,7 +1061,8 @@ class AppLocalizationsEn extends AppLocalizations { String get colorScheme => 'Color Scheme'; @override - String get settingsPageSubtitle => 'Tune playback, account, storage and maintenance controls.'; + String get settingsPageSubtitle => + 'Tune playback, account, storage and maintenance controls.'; @override String get appearanceSettings => 'Appearance'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 84910c2..62871ac 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -84,19 +84,22 @@ class AppLocalizationsZh extends AppLocalizations { String get webLoginBrowserMissingTitle => '未找到支持的浏览器'; @override - String get webLoginBrowserMissingDesc => '请安装 Chrome、Chromium、Edge、Brave、Vivaldi 或 Firefox 后使用 Linux 网页登录,或改用扫码登录 / 手动 Cookie 登录。'; + String get webLoginBrowserMissingDesc => + '请安装 Chrome、Chromium、Edge、Brave、Vivaldi 或 Firefox 后使用 Linux 网页登录,或改用扫码登录 / 手动 Cookie 登录。'; @override String get webLoginWebView2MissingTitle => '缺少 WebView2 运行时'; @override - String get webLoginWebView2MissingDesc => '当前 Windows 设备缺少 Microsoft Edge WebView2 Runtime。请安装后重试,或使用扫码登录 / 手动 Cookie 登录。'; + String get webLoginWebView2MissingDesc => + '当前 Windows 设备缺少 Microsoft Edge WebView2 Runtime。请安装后重试,或使用扫码登录 / 手动 Cookie 登录。'; @override String get webLoginInitFailedTitle => '网页登录启动失败'; @override - String get webLoginInitFailedDesc => '内置 WebView 无法启动。请确认 WebView2 Runtime 可用,或改用扫码登录 / 手动 Cookie 登录。'; + String get webLoginInitFailedDesc => + '内置 WebView 无法启动。请确认 WebView2 Runtime 可用,或改用扫码登录 / 手动 Cookie 登录。'; @override String get webLoginPageLoadFailed => '登录页面加载失败,请检查网络后重试。'; @@ -128,7 +131,8 @@ class AppLocalizationsZh extends AppLocalizations { String get cookieLoginTitle => 'Cookie登录'; @override - String get cookieLoginDesc => '从浏览器中获取 B 站 Cookie 后填入以下字段。在 bilibili.com 按 F12 → 应用 → Cookie → 找到对应值。'; + String get cookieLoginDesc => + '从浏览器中获取 B 站 Cookie 后填入以下字段。在 bilibili.com 按 F12 → 应用 → Cookie → 找到对应值。'; @override String get cookieRequired => '请填写所有 Cookie 字段'; diff --git a/pubspec.yaml b/pubspec.yaml index fb79cb4..6b40432 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,11 @@ name: busic description: A modern cross-platform music player powered by Bilibili. -publish_to: 'none' +publish_to: "none" version: 0.4.3+18 environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.29.0' + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.29.0" dependencies: flutter: @@ -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 @@ -95,9 +98,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: From b637796197a37365178d8e216d9da0071b7e30f4 Mon Sep 17 00:00:00 2001 From: eon-ic Date: Wed, 15 Jul 2026 09:44:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(linux):=20=E4=BF=AE=E5=A4=8D=20Release?= =?UTF-8?q?=20=E4=B8=8B=20MPRIS=20=E6=B7=B7=E6=B7=86=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E5=8F=8A=E5=BE=AA=E7=8E=AF/=E9=9A=8F=E6=9C=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8D=E5=90=8C=E6=AD=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/services/mpris_service.dart | 52 +++++++++++---- lib/core/services/mpris_service_dbus.dart | 63 +++++++++---------- .../player/application/player_notifier.dart | 6 ++ 3 files changed, 75 insertions(+), 46 deletions(-) diff --git a/lib/core/services/mpris_service.dart b/lib/core/services/mpris_service.dart index fb7489a..7f2fa6a 100644 --- a/lib/core/services/mpris_service.dart +++ b/lib/core/services/mpris_service.dart @@ -73,9 +73,18 @@ class MprisService { _dbusClient!.registerObject(_mprisObject!); // 4. Request the well-known name corresponding to this media player. - _dbusClient!.requestName('org.mpris.MediaPlayer2.busic'); - _bindSystemCommands(); - _mprisObject!.init(); + _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'); } @@ -107,13 +116,13 @@ class MprisService { /// Packs raw song metadata into the standard MPRIS Dict format (a{sv}) /// and notifies the D-Bus daemon of property updates. - Future updateMetadata({ + void updateMetadata({ required String title, required String artist, String? album, String? artUrl, Duration? duration, - }) async { + }) { if (_mprisObject == null) return; // MPRIS specification expects metadata as a string-variant dictionary. @@ -126,30 +135,47 @@ class MprisService { 'mpris:artUrl': DBusString(artUrl ?? ''), }; - await _mprisObject!.updatePlayerProperties({ + _mprisObject?.updatePlayerProperties({ 'Metadata': DBusDict.stringVariant(_mprisObject!.metadata) }); } /// Updates the player's playback state (Playing / Paused / Stopped) on D-Bus. - Future updatePlaybackStatus(bool isPlaying) async { + void updatePlaybackStatus(bool isPlaying) { if (_mprisObject == null) return; String status = isPlaying ? 'Playing' : 'Paused'; _mprisObject!.playbackState = status; - await _mprisObject!.updatePlayerProperties({ + _mprisObject?.updatePlayerProperties({ 'PlaybackStatus': DBusString(status) }); } /// Synchronizes the current playback position (in microseconds) with D-Bus. - Future updatePosition(Duration position) async { + void updatePosition(Duration position) { if (_mprisObject == null) return; _mprisObject!.position = position.inMicroseconds; - // await _mprisObject!.updatePlayerProperties({ - // 'Position': DBusInt64(_mprisObject!.position) - // }); } - + /// 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(); diff --git a/lib/core/services/mpris_service_dbus.dart b/lib/core/services/mpris_service_dbus.dart index 0f11f15..286c6eb 100644 --- a/lib/core/services/mpris_service_dbus.dart +++ b/lib/core/services/mpris_service_dbus.dart @@ -1,6 +1,8 @@ // 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'; @@ -13,7 +15,7 @@ class MprisServiceDbus extends DBusObject { // --- Standard MPRIS Player properties --- late Map metadata = {}; late String playbackState = 'Stopped'; - late String loopStatus = ''; + late String loopStatus = 'None'; late double maximumRate = 1.0; late double minimumRate = 1.0; late int position = 0; // Stored in microseconds. @@ -24,29 +26,6 @@ class MprisServiceDbus extends DBusObject { late bool hasTrackList = false; late bool canControl, canGoNext, canGoPrevious, canPause, canPlay, canSeek; - /// Sets up static capability properties indicating what features the player supports. - init() { - canQuit = true; - canRaise = true; - hasTrackList = false; - canControl = true; - canGoNext = true; - canGoPrevious = true; - canPause = true; - canPlay = true; - canSeek = true; - - DBusBoolean bTrue = const DBusBoolean(true); - updatePlayerProperties({ - 'CanControl': bTrue, - 'CanGoNext': bTrue, - 'CanGoPrevious': bTrue, - 'CanPause': bTrue, - 'CanPlay': bTrue, - 'CanSeek': bTrue, - }); - } - // --- External control callbacks injected by the service coordinator --- VoidCallback? onPlay; VoidCallback? onPause; @@ -58,17 +37,33 @@ class MprisServiceDbus extends DBusObject { ValueSetter? setMode; /// Broadcasts property changes over the D-Bus bus to keep system clients in sync. - Future updatePlayerProperties(Map properties) async { - await emitPropertiesChanged( - 'org.mpris.MediaPlayer2.Player', // Target interface holding these properties. + 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); + : 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 { @@ -133,7 +128,7 @@ class MprisServiceDbus extends DBusObject { if (loopStatus == value) return DBusMethodSuccessResponse(); loopStatus = value; - await updatePlayerProperties({'LoopStatus': DBusString(value)}); + updatePlayerProperties({'LoopStatus': DBusString(value)}); switch(value) { case 'Playlist': @@ -143,7 +138,6 @@ class MprisServiceDbus extends DBusObject { case 'None': setMode?.call(PlayMode.sequential); } - if (shuffle) { await setShuffle(false); } @@ -172,7 +166,7 @@ class MprisServiceDbus extends DBusObject { if (shuffle == value) return DBusMethodSuccessResponse(); shuffle = value; - await updatePlayerProperties({'Shuffle': DBusBoolean(value)}); + updatePlayerProperties({'Shuffle': DBusBoolean(value)}); if (shuffle) { setMode?.call(PlayMode.shuffle); @@ -302,10 +296,13 @@ class MprisServiceDbus extends DBusObject { 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); // 安全调用 - await updatePlayerProperties({'Volume': DBusDouble(volume)}); + updatePlayerProperties({'Volume': DBusDouble(volume)}); return DBusMethodSuccessResponse(); } diff --git a/lib/features/player/application/player_notifier.dart b/lib/features/player/application/player_notifier.dart index e172fdf..483f0f1 100644 --- a/lib/features/player/application/player_notifier.dart +++ b/lib/features/player/application/player_notifier.dart @@ -556,12 +556,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.