shinyford / nowplaying

Other
9 stars 11 forks source link

NowPlaying

A Flutter plugin for iOS and Android which surfaces metadata around the currently playing audio track on the device.

On Android nowplaying makes use of the NotifiationListenerService, and shows any track revealing its play state via a notification.

On iOS nowplaying is restricted to access to music or media played via the Apple Music/iTunes app, or via Spotify.

Access to Spotify track listings is dependent on the user signing in to Spotify and authorising the app for its use.

Installation

Add nowplaying as a dependency in your pubspec.yaml file:

dependencies:
    nowplaying: ^3.0.3

iOS

Add the following usage to your ios/Runner/Info.plist:

<key>NSAppleMusicUsageDescription</key>
<string>We need this to show you what's currently playing</string>

Android

To enable the notification listener service, add the following block to your android/app/src/main/AndroidManifest.xml, just before the closing </application> tag:

<service android:name="com.gomes.nowplaying.NowPlayingListenerService"
    android:label="NowPlayingListenerService"
    android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
    <intent-filter>
        <action android:name="android.service.notification.NotificationListenerService" />
    </intent-filter>
</service>

For Android 11 or later

As stated in https://developer.android.com/preview/privacy/package-visibility:

Android 11 changes how apps can query and interact with other apps that the user has installed on a device. Using the new element, apps can define the set of other apps that they can access. This element helps encourage the principle of least privilege by telling the system which other apps to make visible to your app, and it helps app stores like Google Play assess the privacy and security that your app provides for users.

If your app targets Android 11, you might need to add the element in your app's manifest file. Within the element, you can specify apps by package name or by intent signature.

So you either have to stop what you are doing, or request to access information about certain packages, or - if you have reasons for it - use the permission [QUERY_ALL_PACKAGES][https://developer.android.com/reference/kotlin/android/Manifest.permission#query_all_packages].

Query and interact with specific packages

To query and interact with specific packages you would update your AndroidManifest.xml like this:

<manifest ...>
    ...
    <queries>
        <package android:name="com.example.store" />
        <package android:name="com.example.services" />
    </queries>
    ...
     <application ...>
    ...
</manifest>
Query and interact with all apps

Have an app that needs to be able to ask for information for all apps? Add the following to AndroidManifest.xml:

<manifest ...>
     ...
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
     ...
     <application ...>
     ...
</manifest>

Note: For use queries you should write the queries code out of the application tag, not inside the application tag

You'll also need to update the gradle version to a proper version, which supports Android 11 (if you don't have it already)

Usage

Initialisation

Initialise the nowplaying service by starting its instance:

await NowPlaying.instance.start();

This can be done anywhere, including prior to the runApp command.

Permissions

iOS automatically has the required permissions to access now-playing data, via the usage key added during the installation phase.

Android users must give explicit permission for the service to access the notification stream from which now-playing data is extracted.

Test for whether permissions have been given or not via the instance's isEnabled method:

final bool isEnabled = await NowPlaying.instance.isEnabled();
// isEnabled() always returns true on iOS
if (!isEnabled) {
    ...
}

The Android settings page for this permission is a little hard to find, so NowPlaying includes a convenience method to open it:

NowPlaying.instance.requestPermissions();

To avoid annoying a user by e.g. showing the permissions page on every app restart, navigation to this page should be limited: as such, the unparameterised requestPermissions function will only open the settings page once for any given install of the app. It returns a boolean: true the first time, when the page has been successfully shown; also true if permission has already been granted (in which case the settings page is not shown); or false if this is a second or later call to the method, with navigation to the settings page prohibited. (Note that requestPermissions() always returns true on iOS).

final bool hasShownPermissions = await NowPlaying.instance.requestPermissions();

If you really need to show the permissions page a second time, probably after gently explaining to the user why, you can force it open:

if (!hasShownPermissions) {
    final bool pleasePleasePlease = await Navigator.of(context).pushNamed('ExplainAgainReallyNicelyPage');
    if (pleasePleasePlease) NowPlaying.instance.requestPermissions(force: true);
}

(although this still won't show the settings page if permission is already enabled.)

Spotify

Access to spotify requires a client ID and client secret, available from the Spotify Developer Dashboard. Once you have these credentials, pass them into the NowPlaying start function:

  await NowPlaying.instance.start(
    ...,
    spotifyClientId: '<CLIENT ID>',
    spotifyClientSecret: '<CLIENT SECRET>',
    ...
  );

Once credentials have been provided, you need to connect to spotify:

if (NowPlaying.spotify.isEnabled && NowPlaying.spotify.isUnconnected) {
  Navigator.of(context).push(
    MaterialPageRoute(builder: (context) => NowPlaying.spotify.signInPage(context)),
  );
}

If required, this will bring up a Spotify sign-in page in a webview.

Accessing current now-playing metadata

Now-playing metadata is deliverd into the parent app via a stream of NowPlayingTrack objects, exposed as NowPlaying.instance.stream. This can be consumed however you'd usually consume a stream, e.g.:

StreamProvider.value(
    value: NowPlaying.instance.stream,
    child: MaterialApp(
        home: Scaffold(
            body: Consumer<NowPlayingTrack>(
                builder: (context, track, _) {
                    return Container(
                        ...
                    );
                }
            )
        )
    )
)

The NowPlayingTrack objects contain the following fields:

String title;
String artist;
String album;
String genre;
Duration duration;
Duration progress; // check note below
NowPlayingState state;
ImageProvider image;
ImageProvider icon;
String source;

where NowPlayingState is defined as:

enum NowPlayingState {
  playing, paused, stopped
}

...which is hopefully self-explanatory.

icon and source fields

The source of a track is the package name of the app playing the current track: com.spotify.music, for example. On iOS this is always com.apple.music or 'Spotify'

The icon image provider, if not null, supplies a small, transparent PNG containing a monochrome logo for the originating app. While monochrome, this PNG is not necessarily black: so for consistency, it's probably worth adding color: Colors.somethingNice and colorBlendMode: BlendMode.srcIn or similar to any Image widget.

The progress field

As is probably obvious, progress is a duration describing how far through the track the player has progressed, in milliseconds: how much of a track has been played, in other words.

Note that no new track is emitted on the stream as a track progresses: stream updates only happen when the track changes state (playing to paused; vice versa; new track starts; and so on). However, the progress field of a track will give you an instantaneous 'correct' value every time it's polled, so to see progress updating in real time create a stateful widge to expose it:

class TrackProgressIndicator extends StatefulWidget {
  final NowPlayingTrack track;

  TrackProgressIndicator(this.track);

  @override
  _TrackProgressIndicatorState createState() => _TrackProgressIndicatorState();
}

class _TrackProgressIndicatorState extends State<TrackProgressIndicator> {
  Timer _timer;

  @override
  void initState() {
    _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
    super.initState();
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(widget.track.progress.toString().split('.').first.padLeft(8, '0'));
  }
}

Album art and associated images

Usually - and almost always, on Android - a track will contain an appropriate ImageProvider in its image field, containing album art or similar.

On iOS, however, there is a bug or badly documented policy that means album art is only made available if the track being played is in your local library: any tracks streamed from e.g. Apple music playlists are image-free.

NowPlaying can attempt to resolve missing images for you. However, this is a relatively heavy process in terms of memory and processing, so is turned off by default. To enable missing image resolution, set the resolveImages parameter to true when starting the instance:

await NowPlaying.instance.start(..., resolveImages: true, ...);

The default image resolution process:

Overriding the image resolver

You may decide that you want to resolve missing images in a different way, or even override images that have already been found from the metadata. In this case, supply a new image resolver when starting the instance:

await NowPlaying.instance.start(..., resolver: MyImageResolver(), ...);

...

class MyImageResolver implements NowPlayingImageResolver {
    @override
    Future<ImageProvider?> resolve(NowPlayingTrack track) async {
        ...
    }
}

SemVer use

Credits

Thanks to Fábio A. M. Pereira for his Notification Listener Service Example, which provided inspiration (and in some cases, let's be honest, actual code) for the Android implementation.