The Basics of Background Audio

Discussion in 'Live RSS Feeds' started by News, Jan 13, 2016.

  1. News

    News Extraordinary Robot
    News Feed

    Joined:
    Jun 27, 2006
    Messages:
    26,189
    Likes Received:
    20
    Background audio in a Universal Windows Platform (UWP) app is a powerful way to provide a more seamless audio experience to your users. This post will walk you through setting up background audio, reacting to device media controls, communicating with the background media player, and managing playlists.

    Before we get started, you need to know that background playback consists of two processes:

    1. the foreground app (the app UI and client logic)
    2. the background playback task (the audio playback logic and background services)

    In the sample app we’re developing, the user selects and controls the audio, which is then played in the background. By definition, background audio continues playing even when the app is no longer in the foreground.

    The background task communicates with Windows through the System Media Transport Controls (SMTC), which lets your app use the media controls and update metadata for the display. It also listens for foreground app events broadcast by the BackgroundMediaPlayer.

    Note: You’ll likely want to install Update 1 for Visual Studio 2015 before working your way through this code. It contains the latest Windows Phone emulators.

    https://windowsdeveloper.azureedge.net/images/BackgroundAudio.mp4
    Setting up the Background Audio


    The background playback task is implemented through IBackgroundTask, like any other UWP background task, except in this case, we add logic for audio services. Here, we take a look at how that’s done.

    Setting up the Background Task


    The IBackgroundTask interface gives us an entry point for running code in the background. It provides one method, IBackgroundTask.Run, that is called by Windows to perform the task’s work. We use the Run method to set up the objects we need to manage the media playback.

    The IBackgroundTask.Run method is invoked the first time that one of the following occurs:

    • the Current is accessed from the foreground app
    • a handler is registered for the MessageReceivedFromBackground event

    Here’s a quick visual to show how this all fits together:

    [​IMG]

    We’ll take a close look at the background audio task by walking through the code in the sample on GitHub.

    External References


    The first block of code is a standard set of ‘using’ directives to import our namespaces and Windows types. Note the Background Audio resources listed in the last block. The BackgroundAudioShared namespace provides utilities for formatting data and persisting the app state. The BackgroundAudioShared.Messages namespace provides helper classes for decoding messages from the foreground app.


    using System;
    using System.Diagnostics;
    using System.Threading;
    using System.Linq;
    using Windows.ApplicationModel.Background;
    using Windows.Media;
    using Windows.Media.Playback;

    using Windows.Foundation.Collections;
    using Windows.Storage;
    using Windows.Media.Core;
    using System.Collections.Generic;

    using BackgroundAudioShared;
    using Windows.Foundation;
    using BackgroundAudioShared.Messages;
    using Windows.Storage.Streams;

    The Background Task


    We implement the IBackgroundTask interface in the BackgroundAudioTask class. This class needs to refer to the media controls, the playback list, application state, and events. We declare the necessary member variables for this in this top section of the class definition. These will get initialized during the Run method callback. We will discuss how each of these are used in more detail in later sections of this article.


    namespace BackgroundAudioTask
    {
    public sealed class MyBackgroundAudioTask : IBackgroundTask
    {
    #region Private fields, properties
    private const string TrackIdKey = "trackid";
    private const string TitleKey = "title";
    private const string AlbumArtKey = "albumart";
    private SystemMediaTransportControls smtc;
    private MediaPlaybackList playbackList = new MediaPlaybackList();
    private BackgroundTaskDeferral deferral; // Used to keep task alive
    private AppState foregroundAppState = AppState.Unknown;
    private ManualResetEvent backgroundTaskStarted = new ManualResetEvent(false);
    private bool playbackStartedPreviously = false;
    #endregion


    We also set up a couple of helper methods to get the track IDs from playback items. The first gets the track ID of the current item on the playback list. It calls the second helper that gets the track ID from any playback item.


    #region Helper methods
    Uri GetCurrentTrackId()
    {
    if (playbackList == null)
    return null;

    return GetTrackId(playbackList.CurrentItem);
    }

    Uri GetTrackId(MediaPlaybackItem item)
    {
    if (item == null)
    return null; // no track playing

    return item.Source.CustomProperties[TrackIdKey] as Uri;
    }
    #endregion

    The App Manifest


    Before we get to the app manifest, the background audio task should be set up as a Windows runtime project. Currently it must be in a separate project in the solution because the task is compiled into a DLL that is loaded into a special system background task host process. When you run the app, this process will show in Task Manager as Windows.Media.BackgroundPlayback.exe.

    We need to set up a few things in the App Manifest after creating the background audio task. MyBackgroundAudioTask needs to be you registered in the Package.appxmanifest to handle audio events. In the declarations section, you need to specify the following:

    Declarations section Entries
    Supported declarations Background Tasks
    Supported task types (properties) Audio
    Entry point BackgroundAudioTask.MyBackgroundAudioTask
    The Run Method


    The Run method is the heart of the BackgroundAudioTask class. This method will be called by Windows the first time the Media Player is accessed from the foreground. This is where we connect up to the media player by setting properties and adding event handlers.

    1. We set up the Run method below.


    #region IBackgroundTask and IBackgroundTaskInstance Interface Members and handlers
    /// <summary>
    /// The Run method is the entry point of a background task.
    /// </summary>
    /// <param name="taskInstance"></param>
    public void Run(IBackgroundTaskInstance taskInstance)
    {
    Debug.WriteLine("Background Audio Task " + taskInstance.Task.Name + " starting...");

    1. In this step we subscribe to key events and set up properties to enable the app to show in the UVC and to respond to system media transport control commands, for example, when you press the volume button on your device.


    // Initialize SystemMediaTransportControls (SMTC) for integration with
    // the Universal Volume Control (UVC).
    //
    // The UI for the UVC must update even when the foreground process has been terminated
    // and therefore the SMTC is configured and updated from the background task.
    smtc = BackgroundMediaPlayer.Current.SystemMediaTransportControls;

    smtc.ButtonPressed += smtc_ButtonPressed;
    smtc.PropertyChanged += smtc_PropertyChanged;
    smtc.IsEnabled = true;
    smtc.IsPauseEnabled = true;
    smtc.IsPlayEnabled = true;
    smtc.IsNextEnabled = true;
    smtc.IsPreviousEnabled = true;

    1. Read the persisted state of the foreground app, so that later we will be able to evaluate whether we can send updates to the active foreground application.



    var value = ApplicationSettingsHelper.ReadResetSettingsValue(ApplicationSettingsConstants.AppState);
    if (value == null)
    foregroundAppState = AppState.Unknown;
    else
    foregroundAppState = EnumHelper.Parse<AppState>(value.ToString());

    1. Add handlers for MediaPlayer to sync the cached playback status on SMTC—playing, paused, or closed—with CurrentMediaPlayer.


    BackgroundMediaPlayer.Current.CurrentStateChanged += Current_CurrentStateChanged;

    1. Initialize the message channel so the background task can respond to messages sent by the foreground, for example, in response to UI events.


    BackgroundMediaPlayer.MessageReceivedFromForeground += BackgroundMediaPlayer_MessageReceivedFromForeground;


    Note: We recommend that you register for the message received handler before calling BackgroundMediaPlayer.Current for the first time so that the foreground app doesn’t miss any messages sent from the background process.

    1. In the code below, we send a message to the foreground app, if it is active, that the background task has started. This way the foreground app is aware that it can now send further commands, for example, to begin playback.


    if (foregroundAppState != AppState.Suspended)
    MessageService.SendMessageToForeground(new BackgroundAudioTaskStartedMessage());

    ApplicationSettingsHelper.SaveSettingsValue(ApplicationSettingsConstants.BackgroundTaskState, BackgroundTaskState.Running.ToString());

    1. To keep the background task alive after the Run method completes, we need to ask IBackgroundTaskInstance for a deferral. We need to do this before subscribing to the events below because they use it.


    deferral = taskInstance.GetDeferral();

    1. We then mark the background task as started to unblock the SMTC play operation (see related WaitOne on this signal).


    backgroundTaskStarted.Set();

    1. The app goes through lifecycle states that need to be handled. Here we add handlers for cancellation and completed states. This state must be declared at the end of the Run method because the event may raise immediately before thread execution continues.



    taskInstance.Task.Completed += TaskCompleted;
    taskInstance.Canceled += new BackgroundTaskCanceledEventHandler(OnCanceled); // event may raise immediately before continuing thread excecution so must be at the end
    }

    Task Completed Callback


    Because we took a deferral from the task instance during the Run method, we must call Complete on the deferral whenever the task completes. This is the callback method that we registered with the task to handle the Completed notification.



    void TaskCompleted(BackgroundTaskRegistration sender, BackgroundTaskCompletedEventArgs args)
    {
    Debug.WriteLine("MyBackgroundAudioTask " + sender.TaskId + " Completed...");
    deferral.Complete();
    }

    Cancellation Callback


    The task can be canceled when another media app comes into the foreground and starts playing music, or it can be canceled due to lack of resources if your task consumes more CPU or memory than allowed. If this happens, we save state so that the app can resume gracefully.

    Here, we add some handling for the background task being canceled. This handling gives the app time to save state before it closes and resources are reclaimed by the system.



    private void OnCanceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason)
    {
    Debug.WriteLine("MyBackgroundAudioTask " + sender.Task.TaskId + " Cancel Requested...");
    try
    {
    // immediately set not running
    backgroundTaskStarted.Reset();

    ApplicationSettingsHelper.SaveSettingsValue(ApplicationSettingsConstants.TrackId, GetCurrentTrackId() == null ? null : GetCurrentTrackId().ToString());
    ApplicationSettingsHelper.SaveSettingsValue(ApplicationSettingsConstants.Position, BackgroundMediaPlayer.Current.Position.ToString());
    ApplicationSettingsHelper.SaveSettingsValue(ApplicationSettingsConstants.BackgroundTaskState, BackgroundTaskState.Canceled.ToString());
    ApplicationSettingsHelper.SaveSettingsValue(ApplicationSettingsConstants.AppState, Enum.GetName(typeof(AppState), foregroundAppState));


    At this point, we no longer care if any changes are made to the playlist, so we unsubscribe from it.


    if (playbackList != null)
    {
    playbackList.CurrentItemChanged -= PlaybackList_CurrentItemChanged;
    playbackList = null;
    }


    Similarly, we unsubscribe event handlers. We also call complete on our deferral because we have finished the background work.


    BackgroundMediaPlayer.MessageReceivedFromForeground -= BackgroundMediaPlayer_MessageReceivedFromForeground;
    smtc.ButtonPressed -= smtc_ButtonPressed;
    smtc.PropertyChanged -= smtc_PropertyChanged;

    BackgroundMediaPlayer.Shutdown(); // shutdown media pipeline
    }
    catch (Exception ex)
    {
    Debug.WriteLine(ex.ToString());
    }
    deferral.Complete(); // signals task completion.
    Debug.WriteLine("MyBackgroundAudioTask Cancel complete...");
    }
    #endregion

    Reacting to the SMTC


    The audio app should respond to the media controls on the device, such as volume control, pause, stop, back, and skip. This is accomplished through the system media transport controls, which are configured and updated from the background task, not the foreground app.

    To set this up, we initialize SystemMediaTransportControls for integration with the Universal Volume Control (UVC). We create a function to update the UVC with playback status and information about the current audio.


    #region SysteMediaTransportControls related functions and handlers
    private void UpdateUVCOnNewTrack(MediaPlaybackItem item)
    {
    if (item == null)
    {
    smtc.PlaybackStatus = MediaPlaybackStatus.Stopped;
    smtc.DisplayUpdater.MusicProperties.Title = string.Empty;
    smtc.DisplayUpdater.Update();
    return;
    }

    smtc.PlaybackStatus = MediaPlaybackStatus.Playing;
    smtc.DisplayUpdater.Type = MediaPlaybackType.Music;
    smtc.DisplayUpdater.MusicProperties.Title = item.Source.CustomProperties[TitleKey] as string;



    This code block handles local album art for the display. (Remote URIs are not covered in this sample app.)


    var albumArtUri = item.Source.CustomProperties[AlbumArtKey] as Uri;
    if (albumArtUri != null)
    smtc.DisplayUpdater.Thumbnail = RandomAccessStreamReference.CreateFromUri(albumArtUri);
    else
    smtc.DisplayUpdater.Thumbnail = null;

    smtc.DisplayUpdater.Update();
    }


    We could optionally add some code to cover what happens if an SMTC property is changed—for example if the sound is muted.


    void smtc_PropertyChanged(SystemMediaTransportControls sender, SystemMediaTransportControlsPropertyChangedEventArgs args)
    {
    // Put custom code here
    // For example, you could add code to pause the music if the volume is muted
    }


    At the very least we need handling for each UVC button: play, pause, next, and previous. The following switch statement is a simple dispatcher that forwards these button presses to one of our class methods that will react to it. Note that if this is not run in the background process, we won’t be able to handle button events when the app is suspended.

    Note that the play button presents a special problem. Pressing play could be the event that causes this background process to start running again after being suspended. We’ll wait for the backgroundTaskStarted event to be signaled during our Run method before proceeding so that we know that everything is initialized properly.



    private void smtc_ButtonPressed(SystemMediaTransportControls sender, SystemMediaTransportControlsButtonPressedEventArgs args)
    {
    switch (args.Button)
    {


    We begin with the play button.


    case SystemMediaTransportControlsButton.Play:
    Debug.WriteLine("UVC play button pressed");


    When the background task has been suspended and the SMTC starts it again asynchronously, time is needed to allow the task startup process in the Run method to complete. The WaitOne method lets the app wait for the task to start. Once it starts, this signal continues until shutdown so that it won’t wait again unless it needs to do so.


    bool result = backgroundTaskStarted.WaitOne(5000);
    if (!result)
    throw new Exception("Background Task didn't initialize in time");

    StartPlayback();
    break;


    Now the pause button.


    case SystemMediaTransportControlsButton.Pause:
    Debug.WriteLine("UVC pause button pressed");
    try
    {
    BackgroundMediaPlayer.Current.Pause();
    }
    catch (Exception ex)
    {
    Debug.WriteLine(ex.ToString());
    }
    break;


    And then the next and previous buttons:


    case SystemMediaTransportControlsButton.Next:
    Debug.WriteLine("UVC next button pressed");
    SkipToNext();
    break;
    case SystemMediaTransportControlsButton.Previous:
    Debug.WriteLine("UVC previous button pressed");
    SkipToPrevious();
    break;
    }
    }

    #endregion

    Managing playlists


    We definitely want our user to be able to play a playlist and navigate through it. We’d also like for playback to resume where the user last stopped when the foreground app closed.

    [​IMG]
    A playlist in the desktop version of the sample app.


    We set up some playlist management functions and handlers in the following code blocks. In the following code, we try to resume playback from the previously saved track and position.


    #region Playlist management functions and handlers
    /// <summary>
    /// Start playlist and change UVC state
    /// </summary>
    private void StartPlayback()
    {
    try
    {
    // If playback was already started once we can just resume playing.
    if (!playbackStartedPreviously)
    {
    playbackStartedPreviously = true;

    // If the task was cancelled we would have saved the current track and its position. We will try playback from there.
    var currentTrackId = ApplicationSettingsHelper.ReadResetSettingsValue(ApplicationSettingsConstants.TrackId);
    var currentTrackPosition = ApplicationSettingsHelper.ReadResetSettingsValue(ApplicationSettingsConstants.Position);


    And if we have the current track ID, we find the index of the item by name.


    if (currentTrackId != null)
    {
    // Find the index of the item by name
    var index = playbackList.Items.ToList().FindIndex(item =>
    GetTrackId(item).ToString() == (string)currentTrackId);

    if (currentTrackPosition == null)


    If there is no saved position for how far into the track to begin, we play it from the beginning.


    {
    // Play from start if we dont have position
    Debug.WriteLine("StartPlayback: Switching to track " + index);
    playbackList.MoveTo((uint)index);

    // Begin playing
    BackgroundMediaPlayer.Current.Play();
    }


    Otherwise, we play it from the saved position in the track.


    else
    {
    // Play from exact position otherwise
    TypedEventHandler<MediaPlaybackList, CurrentMediaPlaybackItemChangedEventArgs> handler = null;
    handler = (MediaPlaybackList list, CurrentMediaPlaybackItemChangedEventArgs args) =>
    {
    if (args.NewItem == playbackList.Items[index])
    {


    This only needs to run once for this item, so we unsubscribe.


    playbackList.CurrentItemChanged -= handler


    Then we set the position to start and begin playback.


    var position = TimeSpan.Parse((string)currentTrackPosition);
    Debug.WriteLine("StartPlayback: Setting Position " + position);
    BackgroundMediaPlayer.Current.Position = position;

    // Begin playing
    BackgroundMediaPlayer.Current.Play();
    }
    };


    If the user switches the track, we want to trigger an item changed event.


    playbackList.CurrentItemChanged += handler;

    // Switch to the track which will trigger an item changed event
    Debug.WriteLine("StartPlayback: Switching to track " + index);
    playbackList.MoveTo((uint)index);
    }
    }


    In case the currentTrackId is null (see ‘currentTrackId != null’ above), we just begin playback without setting a track or position.


    else
    {
    // Begin playing
    BackgroundMediaPlayer.Current.Play();
    }
    }


    Another else handles playback in the event no playback was previously started (see ‘if (!playbackStartedPreviously’ above).


    else
    {
    // Begin playing
    BackgroundMediaPlayer.Current.Play();
    }
    }
    catch (Exception ex)
    {
    Debug.WriteLine(ex.ToString());
    }
    }


    Next we add some functions to handle navigation through the playlist. We start by getting the new item, then we update the system view (the display), and get the current track.


    void PlaybackList_CurrentItemChanged(MediaPlaybackList sender, CurrentMediaPlaybackItemChangedEventArgs args)
    {
    // Get the new item
    var item = args.NewItem;
    Debug.WriteLine("PlaybackList_CurrentItemChanged: " + (item == null ? "null" : GetTrackId(item).ToString()));

    // Update the system view
    UpdateUVCOnNewTrack(item);

    // Get the current track
    Uri currentTrackId = null;
    if (item != null)
    currentTrackId = item.Source.CustomProperties[TrackIdKey] as Uri;


    We also need to notify the foreground app of the change or persist it for later, as shown in the following block.


    if (foregroundAppState == AppState.Active)
    MessageService.SendMessageToForeground(new TrackChangedMessage(currentTrackId));
    else
    ApplicationSettingsHelper.SaveSettingsValue(TrackIdKey, currentTrackId == null ? null : currentTrackId.ToString());
    }


    The following code skips playback to the previous track.


    private void SkipToPrevious()
    {
    smtc.PlaybackStatus = MediaPlaybackStatus.Changing;
    playbackList.MovePrevious();

    }


    And this skips to the next track.


    private void SkipToNext()
    {
    smtc.PlaybackStatus = MediaPlaybackStatus.Changing;
    playbackList.MoveNext();

    // TODO: Work around playlist bug that doesn't continue playing after a switch; remove later
    BackgroundMediaPlayer.Current.Play();

    }
    #endregion


    We will likely want the user to be able to create new playlists, so we add code to handle that. The foreground app creates the playback list by sending it to the background task. Note that this sample does not include a playlist UI.


    #region Background Media Player Handlers
    void CreatePlaybackList(IEnumerable<SongModel> songs)
    {


    We begin by making a new list and enabling looping.


    playbackList = new MediaPlaybackList();
    playbackList.AutoRepeatEnabled = true;


    Then we add playback items to the list.


    foreach (var song in songs)
    {
    var source = MediaSource.CreateFromUri(song.MediaUri);
    source.CustomProperties[TrackIdKey] = song.MediaUri;
    source.CustomProperties[TitleKey] = song.Title;
    source.CustomProperties[AlbumArtKey] = song.AlbumArtUri;
    playbackList.Items.Add(new MediaPlaybackItem(source));
    }


    We want to make sure the new playlist doesn’t automatically start. We assign the list to the media player and add a handler for future playlist item changes.


    BackgroundMediaPlayer.Current.AutoPlay = false;

    // Assign the list to the player
    BackgroundMediaPlayer.Current.Source = playbackList;

    // Add handler for future playlist item changes
    playbackList.CurrentItemChanged += PlaybackList_CurrentItemChanged;
    }


    You should be aware that this example demonstrates only how the playlist works with the background media player: MediaPlaybackList is the type of list you would pass into a MediaPlayer. If you would like to explore more ways to work with playlists or creating a playlist UI, it’s worth taking a look at the playlists sample on GitHub and the adaptive streaming sample for streaming audio. The playlists sample shows how you might load a list of songs from a serialized file, a different kind of list that we show here. When deserializing from file, you would still need to add each playback item to a MediaPlaybackList in a way similar to the foreach (var song in songs) statement above.

    Communicating with the Background Audio Task


    Finally, we create a set of message handlers for the background media player, so the foreground app can send it messages like “App suspending,” “Starting playback,” or “Update playlist.”


    void BackgroundMediaPlayer_MessageReceivedFromForeground(object sender, MediaPlayerDataReceivedEventArgs e)
    {
    AppSuspendedMessage appSuspendedMessage;


    The next code block for message handling addresses the app suspending. At this point, the app can save the task state.


    if(MessageService.TryParseMessage(e.Data, out appSuspendedMessage))
    {
    Debug.WriteLine("App suspending"); // App is suspended, you can save your task state at this point
    foregroundAppState = AppState.Suspended;
    var currentTrackId = GetCurrentTrackId();
    ApplicationSettingsHelper.SaveSettingsValue(ApplicationSettingsConstants.TrackId, currentTrackId == null ? null : currentTrackId.ToString());
    return;
    }


    The following Windows Phone image depicts switching from the background audio sample app to play a song from the SD Card in another app. When this happens, the background audio sample suspends and records the track and position at which playback stopped, so it can start playback from the same place when the app is resumed. Be sure to check out the video near the top of this post to see this in action.

    [​IMG]

    In the code below, we handle the messages for the app resuming. When the app is resumed, it subscribes to the message channel.


    AppResumedMessage appResumedMessage;
    if(MessageService.TryParseMessage(e.Data, out appResumedMessage))
    {
    Debug.WriteLine("App resuming"); // App is resumed, now subscribe to message channel
    foregroundAppState = AppState.Active;
    return;
    }


    The following code handles messages from the foreground app for starting playback and skipping tracks.



    StartPlaybackMessage startPlaybackMessage;
    if(MessageService.TryParseMessage(e.Data, out startPlaybackMessage))
    {
    //Foreground App process has signalled that it is ready for playback
    Debug.WriteLine("Starting Playback");
    StartPlayback();
    return;
    }

    SkipNextMessage skipNextMessage;
    if(MessageService.TryParseMessage(e.Data, out skipNextMessage))
    {
    // User has chosen to skip track from app context.
    Debug.WriteLine("Skipping to next");
    SkipToNext();
    return;
    }

    SkipPreviousMessage skipPreviousMessage;
    if(MessageService.TryParseMessage(e.Data, out skipPreviousMessage))
    {
    // User has chosen to skip track from app context.
    Debug.WriteLine("Skipping to previous");
    SkipToPrevious();
    return;
    }


    The remaining code in the message handling routine covers changed tracks and updated playlists.


    TrackChangedMessage trackChangedMessage;
    if(MessageService.TryParseMessage(e.Data, out trackChangedMessage))
    {
    var index = playbackList.Items.ToList().FindIndex(i => (Uri)i.Source.CustomProperties[TrackIdKey] == trackChangedMessage.TrackId);
    Debug.WriteLine("Skipping to track " + index);
    smtc.PlaybackStatus = MediaPlaybackStatus.Changing;
    playbackList.MoveTo((uint)index);

    return;
    }

    UpdatePlaylistMessage updatePlaylistMessage;
    if(MessageService.TryParseMessage(e.Data, out updatePlaylistMessage))
    {
    CreatePlaybackList(updatePlaylistMessage.Songs);
    return;
    }
    }


    We’ll also need to handle sending the SMTC information on whether the media player is playing, paused, or closed.


    void Current_CurrentStateChanged(MediaPlayer sender, object args)
    {
    if (sender.CurrentState == MediaPlayerState.Playing)
    {
    smtc.PlaybackStatus = MediaPlaybackStatus.Playing;
    }
    else if (sender.CurrentState == MediaPlayerState.Paused)
    {
    smtc.PlaybackStatus = MediaPlaybackStatus.Paused;
    }
    else if (sender.CurrentState == MediaPlayerState.Closed)
    {
    smtc.PlaybackStatus = MediaPlaybackStatus.Closed;
    }
    }
    #endregion
    } //Final curly brace for MyBackgroundAudioTask
    } //Final curly brace for BackgroundAudioTask

    Wrapping Up


    Now that you understand how to utilize background audio, you might like to check out the background audio UWP sample on GitHub. It contains both foreground app and background task sample code, and you can use it as a starting point for your own app. You may also be interested in NPR One’s story about implementing background audio.

    Continue reading...
     

Share This Page

Loading...