Creating an Audio Feed in Flutter (Part 2)

Creating an Audio Feed in Flutter (Part 2)

Play Audio Files from Google Drive in Flutter

Table of Content

  • Intro
  • List Files from Google Drive
  • Streaming Audio
  • ListView.builder
  • Audio Lock
  • Conclusion

The final code can be found here: https://github.com/jonaylor89/AudioFeed

Intro

Prereqs

This post builds off of the code from the previous part so if you want to follow along with the code, I recommend reading part 1 first. Please also have a Google Drive folder with some MP3 files in it since I'm going to be deleting the folder and infrastructure I'm using before this is published. This tutorial is going to be pretty Google Cloud heavy so if you aren't familiar with it, I'd recommend reading some docs and familiarizing yourself beforehand, otherwise things like "service account" and "cloud function" won't make sense.

In the last part, we were able to pick and play mp3 files from our device's storage! In this part, we'll be building off of that to be able to locate files in a Google Drive folder and stream the audio from the folder, to our device. Kind of like a poor-man's Spotify. This sounds simple enough but there are some subtle problems that'll require things to be a little less straightforward than what we would like. A brief list of what this part will add is:

  • List audio files in a drive folder
  • stream audio from a specific audio file in the same drive folder

When connecting to Google Drive, it is extremely important that you don't store credentials on the app because anything stored on the app (e.g. passwords, service account info, OAuth keys, etc.) will get packaged and is visible to end-users. Therefore, in this tutorial, we will also be building a serverless function to proxy our requirements to the Google Drive API so we don't have to store any service account credentials on the device. Since our app isn't going onto the app store and we want to keep things simple, the serverless function is going to allow for unauthenticated requests so for any production use cases, we'd want to add some kind of Sign-In feature to restrict who can ping our API. For the actual streaming portion though, we won't need to go through a middle man since we can just make our Google Drive folder public, but again, in any production use cases, this would need to be changed (and honestly Firebase or something similar would need to be used).

Blank_diagram.png

List Files from Google Drive

Create Google Cloud Project

Before we start messing with the app, we'll start by building the cloud function that'll read from our Google Drive folder. To do that we'll need a new Google Cloud Project and for this project, we'll need to enable the Google Drive API. :

export PROJECT_ID=<SOME-PROJECT-ID>
gcloud projects create $PROJECT_ID --name="AudioFeed"
gcloud config set project $PROJECT_ID
gcloud services enable drive.googleapis.com

LOOPS.png

Cloud Function to List Files

With the project made, we can now quickly create a short script to interact with Google Drive and pull the name and id for the audio files. It's important that we get both the name and id since the name (i.e. foobar.mp3) is what'll be used to display on the frontend and the id is what we'll need to stream the audio from Google Drive later on.

.
├── analysis_options.yaml
├── android
├── build
├── functions
            └── ListDriveFiles
               ├── main.py
               └── requirements.txt
├── ios
├── lib
├── macos
├── pubspec.lock
├── pubspec.yaml
├── README.md
├── test
└── web
# Import Google Libraries
from googleapiclient.discovery import build
from google.oauth2 import service_account

def main(request):

    FILETYPE = "audio/mpeg"

        # Build Google Drive APi
    service = build("drive", "v3")

    results = (
                # List Files
        service.files()

                # Filter for just audio files and give us only the id and name
        .list(fields="files(id, name)", q=f"mimeType='{FILETYPE}'")
        .execute()
    )

    items = results.get("files", [])

        # Format the result
    item_dict = map(lambda x: {"name": x["name"], "id": x["id"]}, items)

        # Response with JSON payload
    return {"files": list(item_dict)}

As you can see, the script isn't very long since nothing crazy complex is being done. The Google Drive API provides a reasonably simple interface for grabbing what we need. For more info on it, you can read the docs here. With the function written, we just need to specify our dependencies and then deploy.

google-api-python-client
google-auth-httplib2
google-auth-oauthlib
gcloud functions deploy list-drive-files --entry-point main --runtime python39 --trigger-http

Share Folder with Function

After it has deployed, we need to give the function access to the Google Drive folder. Luckily there is an easy way to do this without anything complex using IAM or whatnot. We simply need to go to the Google Drive folder and share it (exactly like we would if we were sharing it with another person) with the service account email for our cloud function which is automatically generated for us by Google.

SHARE.png

And Voila! It works!

FUNCTION.png

Call Cloud Function in the App

Finally, let's begin editing our actual application code. To begin, we first need to gut most of the AudioFeedView since we no longer need the FloatingActionButton , everything involving state, and any of the other logic for displaying audio picked from local storage. Given that we're removing everything involving state, we'll also be converting the AudioFeedView to a stateless widget. This is the widget where we'll be calling our cloud function in a bit so it'll make things nice and easy to strip everything unnecessary out now.

import 'package:audio_feed/components/audio_container.dart';
import 'package:flutter/material.dart';

class AudioFeedView extends StatelessWidget {
  AudioFeedView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Center(
          child: Text('Audio Feed'),
        ),
      ),
      body: Center(
                child: Container(),
      ),
    );
  }
}

With that done, we're in a good spot to add the logic for calling our cloud function. Traditionally, in other fronend frameworks, we'd usually do this with some kind of state management and call out cloud function in some form of initState() . Of course we could do this with Flutter, however, fortunately, we can leverage Flutter's FutureBuilder widget which alongs us to provide it a future (i.e. our cloud function http call) and it will manage the state and keep the UI in sync with it for us.

import 'dart:convert';
import 'package:http/http.dart' as http;

import 'package:audio_feed/components/audio_container.dart';
import 'package:flutter/material.dart';

class AudioFeedView extends StatelessWidget {
  AudioFeedView({Key? key}) : super(key: key);

  final String cloudFunctionUrl = "<YOUR-FUNCTION-URL>";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Center(
          child: Text('Audio Feed'),
        ),
      ),
      body: Center(
        child: FutureBuilder<http.Response>(
          future: http.get(Uri.parse(cloudFunctionUrl)),
          builder: (context, snapshot) {

                        // Show a loading indictator while we wait
            if (!snapshot.hasData) {
              return const CircularProgressIndicator();
            }

            final response = snapshot.data!;
            final List<dynamic> audioFiles = jsonDecode(response.body)["files"];
            return Container() // [!!!] This is a placeholder
          },
        ),
      ),
    );
  }
}

Streaming Audio

Moving to the AudioContainer widget, how do we play remote audio files in Google Drive from our app? Well, the answer to that question has two answers.

The first piece, "how do we play remote audio files from our app" is solved by our old friend just_audio which has a method setUrl() that allows us to point our audio player to a URL and strea the audio to our device ✅

The second piece of that "how to we play remote audio files in Google Drive" is bit more tricky. It isn't very trivial to find the exact URL to access files from a public Google Drive folder. Luckily, (and I mean very luckily), there is a stackoverflow post telling us that the "secret url" is https://drive.google.com/uc?export=view&id=<FILE_ID>

(Updating this widget is going to require a lot of little spare updates so I'll make it more clear what exactly is changing)

import 'package:audio_feed/components/seek_bar.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';

class AudioContainer extends StatefulWidget {
  const AudioContainer({
    Key? key,

      // ❗ New widget parameters
    required this.audioTitle,
    required this.audioId,
  }) : super(key: key);

    // ❗ New widget parameters
  final String audioTitle;
  final String audioId;

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

class _AudioContainerState extends State<AudioContainer> {
    // ❗ New widget parameters
  String get _audioTitle => widget.audioTitle;
  String get _audioId => widget.audioId;
  ValueNotifier<String> get _audioLock => widget.audioLock;

    // ❗ Google Drive Audio Streaming URL
  final String _drivePrefix = "https://drive.google.com/uc?export=view&id=";

  final AudioPlayer _player = AudioPlayer();

  void initAudio() {
    _player.setLoopMode(LoopMode.one);
  }

  @override
  void initState() {
    initAudio();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    _player.setUrl(_drivePrefix + _audioId); // ❗ Change from _player.setFilePath(...)
    return Column(
      children: [
        const SizedBox(height: 50.0),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
          child: Text(
            _audioTitle,   // ❗ Change from _audioFile.path...
            style: const TextStyle(
              fontSize: 25,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),

                ...
      ],
    );
  }
}

ListView.builder

In order to display our new AudioContainer , we need to go back to the AudioFeedView and add the widget for each audio file in the Google Drive folder. To do this, we'll need to take advantage of another amazing Flutter widget: ListView.builder() . This widget allows us to supply it some list of elements as well as a function describing how to render each element. In our case, the list we're providing is the list of audio files we got back from our cloud function, and the function we're using to render each element just returns our AudioContainer .

Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Center(
          child: Text('Audio Feed'),
        ),
      ),
      body: Center(
        child: FutureBuilder<http.Response>(
          future: http.get(Uri.parse(cloudFunctionUrl)),
          builder: (context, snapshot) {
            if (!snapshot.hasData) {
              return const CircularProgressIndicator();
            }

            final response = snapshot.data!;
            final List<dynamic> loops = jsonDecode(response.body)["files"];
            return ListView.builder(
              itemCount: loops.length,
              itemBuilder: (context, index) {
                final Map<String, dynamic> loop = loops[index];
                return AudioContainer(
                  audioTitle: loop["name"]! as String,
                  audioId: loop["id"]! as String,
                );
              },
            );
          },
        ),
      ),
    );
  }

And if all went well...

Screenshot_20211018-152156.jpg

Audio Locking

One last thing though...

There is one minor bug with the way things are implemented right now and it's that if we tap the play button on music audio clips, it'll play them all at the same time. We need a mechanism to pause the current playing audio clip when we click on a new one. Once again though, Flutter has us covered with a type called ValueNotifier . This type allows us to wrap around another data type (e.g. String) and attach listeners to it that are notified when the value changes. In our case, the value we want to wrap around is the id for the audio file returned from Google Drive, and when the value changes, we want to pause/play audio appropriately. We can go ahead and add the ValueNotifier to our AudioFeedView and pass it to our AudioContainer.

class AudioFeedView extends StatelessWidget {
  AudioFeedView({Key? key}) : super(key: key);

  ValueNotifier<String> audioLock = ValueNotifier('');

  final String cloudFunctionUrl = "...";

  @override
  Widget build(BuildContext context) {

        ...

            return AudioContainer(
        audioTitle: loop["name"]! as String,
        audioId: loop["id"]! as String,
        audioLock: audioLock,
      );

        ...

  }
}

In our AudioContainer , we'll need to add the new parameter and write a small callback saying if the audioLock value changes, and the value isn't our current id, then pause. That way, only one audio file is playing at a time and its id is stored in that audioLock .

class AudioContainer extends StatefulWidget {
  const AudioContainer({
    Key? key,
    required this.audioTitle,
    required this.audioId,
    required this.audioLock,
  }) : super(key: key);

  final String audioTitle;
  final String audioId;
  final ValueNotifier<String> audioLock;

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

class _AudioContainerState extends State<AudioContainer> {
  String get _audioTitle => widget.audioTitle;
  String get _audioId => widget.audioId;
  ValueNotifier<String> get _audioLock => widget.audioLock;

  ...

  *void onAu*dioLockChange() {
    if (_audioLock.value != _audioId) {
      _player.pause();
    }
  }

  void initAudio() {
    _audioLock.addListener(onAudioLockChange);
    _player.setLoopMode(LoopMode.one);
  }

  @override
  void initState() {
    ...
  }

  @override
  Widget build(BuildContext context) {
        ...
  }
}

Just to add some finishing touches, we'll also get fancy and add a Mixin called AutomaticKeepAliveMixin which gives us the ability to prevent the audio clips from being destroyed when they aren't rendered. Usually, you won't want to do this but because we have so few audio clips, it'll make things faster.

class _AudioContainerState extends State<AudioContainer> with AutomaticKeepAliveClientMixin {

    ...

  @override
  bool get wantKeepAlive => true;
}

Conclusion

We're done! There's obviously tons of other stuff you could add to this (and it could probably be a whole lot pretty as well) but at this point, we have a music player app backed by Google Drive. In production situations, something more practical like s3 or Google Cloud Storage (and Firebase) should be used but for something fun to show off to people, this is kinda cool. This is also a really good demonstration of how powerful Flutter is and how simple it is nowadays to create really rich, practical applications with just one codebase. If you haven't already tried following along and writing the code yourself of clone the code from GitHub and add your own Google Drive Folder and cloud function!

The final code can be found here: https://github.com/jonaylor89/AudioFeed

In The Loop Community

We hope you've learned a bit more about the lifecycle of a song. The entire purpose of In The Loop is to connect growing artists and producers and allow them to showcase their work in a space for constructive feedback while also participating in competitions tailored to the community. Currently, we are creating our community on Discord as we finalize our application, In The Loop. We are always looking for new beta testers to help improve the application so that we can be sure to match our app to user wants and needs. If you would be interested in being a beta tester, we have an Instagram page @itl_studios and we can give more information there if you shoot us a direct message!

Discord: Stay In The Loop

Also, if you enjoyed this blog post or any of our previous posts, be sure to join our Discord server which can be found at discord.gg/c7Hf3HX6Hh

Further Reading & Links