← Back to Blog

Flutter Audio Player Localization: Podcast and Music App Translations

flutteraudiopodcastmusiclocalizationmedia-player

Flutter Audio App Localization: Podcasts, Music Players, and Audio Content

Build audio applications that speak every language. This guide covers localizing audio player interfaces, podcast apps, music streaming, and audio content management in Flutter.

Audio App Localization Challenges

Audio apps need localization for:

  • Playback controls - Play, pause, skip, shuffle labels
  • Time displays - Duration, elapsed time formats
  • Content metadata - Song titles, artist names, descriptions
  • Accessibility - Screen reader announcements
  • Audio content - Voiceovers, narration, podcasts

Localized Audio Player UI

Complete Audio Player Widget

class LocalizedAudioPlayer extends StatefulWidget {
  final AudioTrack track;

  const LocalizedAudioPlayer({required this.track});

  @override
  State<LocalizedAudioPlayer> createState() => _LocalizedAudioPlayerState();
}

class _LocalizedAudioPlayerState extends State<LocalizedAudioPlayer> {
  late AudioPlayer _player;
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  bool _isPlaying = false;
  double _playbackSpeed = 1.0;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context).toString();

    return Column(
      children: [
        // Track info
        _buildTrackInfo(l10n, locale),

        // Progress bar
        _buildProgressBar(l10n, locale),

        // Playback controls
        _buildControls(l10n),

        // Additional options
        _buildOptions(l10n),
      ],
    );
  }

  Widget _buildTrackInfo(AppLocalizations l10n, String locale) {
    return Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
          // Album art
          ClipRRect(
            borderRadius: BorderRadius.circular(12),
            child: Image.network(
              widget.track.artworkUrl,
              width: 200,
              height: 200,
              fit: BoxFit.cover,
              semanticLabel: l10n.albumArtFor(widget.track.getTitle(locale)),
            ),
          ),
          SizedBox(height: 16),
          // Title
          Text(
            widget.track.getTitle(locale),
            style: Theme.of(context).textTheme.headlineSmall,
            textAlign: TextAlign.center,
          ),
          SizedBox(height: 4),
          // Artist
          Text(
            widget.track.getArtist(locale),
            style: Theme.of(context).textTheme.bodyLarge?.copyWith(
              color: Colors.grey[600],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildProgressBar(AppLocalizations l10n, String locale) {
    final formatter = DurationFormatter(locale: locale, l10n: l10n);

    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16),
      child: Column(
        children: [
          Semantics(
            label: l10n.progressBarLabel(
              formatter.format(_position),
              formatter.format(_duration),
            ),
            child: Slider(
              value: _position.inMilliseconds.toDouble(),
              max: _duration.inMilliseconds.toDouble(),
              onChanged: (value) {
                _player.seek(Duration(milliseconds: value.toInt()));
              },
            ),
          ),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(formatter.format(_position)),
                Text(formatter.formatRemaining(_position, _duration)),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildControls(AppLocalizations l10n) {
    return Padding(
      padding: EdgeInsets.all(16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          // Shuffle
          _buildControlButton(
            icon: Icons.shuffle,
            label: l10n.shuffle,
            onPressed: _toggleShuffle,
            isActive: _isShuffleOn,
          ),

          // Previous
          _buildControlButton(
            icon: Icons.skip_previous,
            label: l10n.previousTrack,
            onPressed: _playPrevious,
            size: 40,
          ),

          // Play/Pause
          _buildPlayPauseButton(l10n),

          // Next
          _buildControlButton(
            icon: Icons.skip_next,
            label: l10n.nextTrack,
            onPressed: _playNext,
            size: 40,
          ),

          // Repeat
          _buildControlButton(
            icon: _getRepeatIcon(),
            label: _getRepeatLabel(l10n),
            onPressed: _toggleRepeat,
            isActive: _repeatMode != RepeatMode.off,
          ),
        ],
      ),
    );
  }

  Widget _buildPlayPauseButton(AppLocalizations l10n) {
    return Semantics(
      label: _isPlaying ? l10n.pause : l10n.play,
      button: true,
      child: InkWell(
        onTap: _togglePlayPause,
        customBorder: CircleBorder(),
        child: Container(
          width: 64,
          height: 64,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Theme.of(context).primaryColor,
          ),
          child: Icon(
            _isPlaying ? Icons.pause : Icons.play_arrow,
            color: Colors.white,
            size: 32,
          ),
        ),
      ),
    );
  }

  Widget _buildControlButton({
    required IconData icon,
    required String label,
    required VoidCallback onPressed,
    double size = 24,
    bool isActive = false,
  }) {
    return Semantics(
      label: label,
      button: true,
      child: IconButton(
        icon: Icon(icon, size: size),
        color: isActive ? Theme.of(context).primaryColor : null,
        onPressed: onPressed,
        tooltip: label,
      ),
    );
  }

  Widget _buildOptions(AppLocalizations l10n) {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          // Playback speed
          _buildSpeedButton(l10n),

          // Sleep timer
          _buildSleepTimerButton(l10n),

          // Download
          _buildDownloadButton(l10n),

          // Share
          _buildShareButton(l10n),
        ],
      ),
    );
  }

  Widget _buildSpeedButton(AppLocalizations l10n) {
    return TextButton(
      onPressed: () => _showSpeedDialog(l10n),
      child: Text(
        l10n.playbackSpeed(_playbackSpeed),
        style: TextStyle(fontWeight: FontWeight.bold),
      ),
    );
  }

  void _showSpeedDialog(AppLocalizations l10n) {
    showModalBottomSheet(
      context: context,
      builder: (context) => Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: EdgeInsets.all(16),
            child: Text(
              l10n.selectPlaybackSpeed,
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ),
          ...[ 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0 ].map((speed) {
            return ListTile(
              title: Text(l10n.speedMultiplier(speed)),
              trailing: _playbackSpeed == speed
                  ? Icon(Icons.check, color: Theme.of(context).primaryColor)
                  : null,
              onTap: () {
                setState(() => _playbackSpeed = speed);
                _player.setPlaybackRate(speed);
                Navigator.pop(context);
              },
            );
          }),
        ],
      ),
    );
  }

  String _getRepeatLabel(AppLocalizations l10n) {
    switch (_repeatMode) {
      case RepeatMode.off:
        return l10n.repeatOff;
      case RepeatMode.one:
        return l10n.repeatOne;
      case RepeatMode.all:
        return l10n.repeatAll;
    }
  }
}

Duration Formatting

Locale-Aware Duration Formatter

class DurationFormatter {
  final String locale;
  final AppLocalizations l10n;

  DurationFormatter({required this.locale, required this.l10n});

  String format(Duration duration) {
    final hours = duration.inHours;
    final minutes = duration.inMinutes % 60;
    final seconds = duration.inSeconds % 60;

    if (hours > 0) {
      return '$hours:${_twoDigits(minutes)}:${_twoDigits(seconds)}';
    }
    return '${minutes}:${_twoDigits(seconds)}';
  }

  String formatRemaining(Duration position, Duration total) {
    final remaining = total - position;
    return '-${format(remaining)}';
  }

  String formatLong(Duration duration) {
    final hours = duration.inHours;
    final minutes = duration.inMinutes % 60;
    final seconds = duration.inSeconds % 60;

    final parts = <String>[];

    if (hours > 0) {
      parts.add(l10n.hoursLong(hours));
    }
    if (minutes > 0) {
      parts.add(l10n.minutesLong(minutes));
    }
    if (seconds > 0 && hours == 0) {
      parts.add(l10n.secondsLong(seconds));
    }

    return parts.join(' ');
  }

  String formatCompact(Duration duration) {
    final hours = duration.inHours;
    final minutes = duration.inMinutes % 60;

    if (hours > 0) {
      return l10n.hoursMinutesCompact(hours, minutes);
    }
    return l10n.minutesCompact(minutes);
  }

  String _twoDigits(int n) => n.toString().padLeft(2, '0');
}

Podcast App Localization

Episode List with Localized Metadata

class LocalizedPodcastEpisodeList extends StatelessWidget {
  final List<PodcastEpisode> episodes;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context).toString();

    if (episodes.isEmpty) {
      return _buildEmptyState(l10n);
    }

    return ListView.builder(
      itemCount: episodes.length,
      itemBuilder: (context, index) {
        return _buildEpisodeTile(episodes[index], l10n, locale);
      },
    );
  }

  Widget _buildEmptyState(AppLocalizations l10n) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.podcasts, size: 64, color: Colors.grey),
          SizedBox(height: 16),
          Text(l10n.noEpisodes),
          SizedBox(height: 8),
          Text(
            l10n.noEpisodesDescription,
            style: TextStyle(color: Colors.grey),
          ),
        ],
      ),
    );
  }

  Widget _buildEpisodeTile(
    PodcastEpisode episode,
    AppLocalizations l10n,
    String locale,
  ) {
    final durationFormatter = DurationFormatter(locale: locale, l10n: l10n);
    final dateFormatter = DateFormat.yMMMd(locale);

    return ListTile(
      leading: ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: Image.network(
          episode.thumbnailUrl,
          width: 56,
          height: 56,
          fit: BoxFit.cover,
        ),
      ),
      title: Text(
        episode.getTitle(locale),
        maxLines: 2,
        overflow: TextOverflow.ellipsis,
      ),
      subtitle: Row(
        children: [
          Text(dateFormatter.format(episode.publishDate)),
          Text(' • '),
          Text(durationFormatter.formatCompact(episode.duration)),
          if (episode.isExplicit) ...[
            Text(' • '),
            Icon(Icons.explicit, size: 16),
          ],
        ],
      ),
      trailing: _buildDownloadStatus(episode, l10n),
      onTap: () => _playEpisode(episode),
    );
  }

  Widget _buildDownloadStatus(PodcastEpisode episode, AppLocalizations l10n) {
    switch (episode.downloadStatus) {
      case DownloadStatus.notDownloaded:
        return IconButton(
          icon: Icon(Icons.download_outlined),
          tooltip: l10n.download,
          onPressed: () => _downloadEpisode(episode),
        );
      case DownloadStatus.downloading:
        return Stack(
          alignment: Alignment.center,
          children: [
            CircularProgressIndicator(
              value: episode.downloadProgress,
              strokeWidth: 2,
            ),
            Text(
              '${(episode.downloadProgress * 100).toInt()}%',
              style: TextStyle(fontSize: 10),
            ),
          ],
        );
      case DownloadStatus.downloaded:
        return Icon(Icons.download_done, color: Colors.green);
      case DownloadStatus.failed:
        return IconButton(
          icon: Icon(Icons.error_outline, color: Colors.red),
          tooltip: l10n.retryDownload,
          onPressed: () => _downloadEpisode(episode),
        );
    }
  }
}

class PodcastEpisode {
  final String id;
  final Map<String, String> titles;
  final Map<String, String> descriptions;
  final String audioUrl;
  final String thumbnailUrl;
  final Duration duration;
  final DateTime publishDate;
  final bool isExplicit;
  final DownloadStatus downloadStatus;
  final double downloadProgress;

  String getTitle(String locale) {
    return titles[locale] ?? titles['en'] ?? titles.values.first;
  }

  String getDescription(String locale) {
    return descriptions[locale] ?? descriptions['en'] ?? '';
  }
}

enum DownloadStatus { notDownloaded, downloading, downloaded, failed }

Sleep Timer Localization

Localized Sleep Timer

class LocalizedSleepTimer extends StatelessWidget {
  final Duration? currentTimer;
  final Function(Duration?) onTimerSet;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: EdgeInsets.all(16),
          child: Row(
            children: [
              Icon(Icons.bedtime),
              SizedBox(width: 12),
              Text(
                l10n.sleepTimer,
                style: Theme.of(context).textTheme.titleLarge,
              ),
            ],
          ),
        ),

        if (currentTimer != null)
          _buildActiveTimer(context, l10n),

        Divider(),

        // Quick options
        ..._buildQuickOptions(l10n),

        // Custom time
        ListTile(
          leading: Icon(Icons.timer),
          title: Text(l10n.customTime),
          onTap: () => _showCustomTimePicker(context, l10n),
        ),

        // End of episode
        ListTile(
          leading: Icon(Icons.audiotrack),
          title: Text(l10n.endOfEpisode),
          onTap: () => onTimerSet(Duration(hours: 99)), // Special value
        ),

        if (currentTimer != null)
          ListTile(
            leading: Icon(Icons.cancel, color: Colors.red),
            title: Text(l10n.cancelTimer),
            onTap: () => onTimerSet(null),
          ),
      ],
    );
  }

  Widget _buildActiveTimer(BuildContext context, AppLocalizations l10n) {
    return Container(
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      padding: EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Theme.of(context).primaryColor.withOpacity(0.1),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Row(
        children: [
          Icon(Icons.timer, color: Theme.of(context).primaryColor),
          SizedBox(width: 12),
          Expanded(
            child: Text(
              l10n.timerActive(_formatTimerDuration(currentTimer!, l10n)),
              style: TextStyle(
                color: Theme.of(context).primaryColor,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ],
      ),
    );
  }

  List<Widget> _buildQuickOptions(AppLocalizations l10n) {
    final options = [
      (Duration(minutes: 5), l10n.fiveMinutes),
      (Duration(minutes: 10), l10n.tenMinutes),
      (Duration(minutes: 15), l10n.fifteenMinutes),
      (Duration(minutes: 30), l10n.thirtyMinutes),
      (Duration(minutes: 45), l10n.fortyFiveMinutes),
      (Duration(hours: 1), l10n.oneHour),
    ];

    return options.map((option) {
      return ListTile(
        title: Text(option.$2),
        trailing: currentTimer == option.$1
            ? Icon(Icons.check, color: Colors.green)
            : null,
        onTap: () => onTimerSet(option.$1),
      );
    }).toList();
  }

  String _formatTimerDuration(Duration duration, AppLocalizations l10n) {
    final minutes = duration.inMinutes;
    if (minutes < 60) {
      return l10n.minutesRemaining(minutes);
    }
    final hours = duration.inHours;
    final remainingMinutes = minutes % 60;
    if (remainingMinutes == 0) {
      return l10n.hoursRemaining(hours);
    }
    return l10n.hoursMinutesRemaining(hours, remainingMinutes);
  }
}

ARB File Structure

{
  "@@locale": "en",

  "play": "Play",
  "pause": "Pause",
  "stop": "Stop",
  "shuffle": "Shuffle",
  "previousTrack": "Previous track",
  "nextTrack": "Next track",

  "repeatOff": "Repeat off",
  "repeatOne": "Repeat one",
  "repeatAll": "Repeat all",

  "albumArtFor": "Album art for {title}",
  "@albumArtFor": {
    "placeholders": {"title": {"type": "String"}}
  },

  "progressBarLabel": "{current} of {total}",
  "@progressBarLabel": {
    "placeholders": {
      "current": {"type": "String"},
      "total": {"type": "String"}
    }
  },

  "playbackSpeed": "{speed}x",
  "@playbackSpeed": {
    "placeholders": {"speed": {"type": "double"}}
  },
  "selectPlaybackSpeed": "Select playback speed",
  "speedMultiplier": "{speed}x speed",
  "@speedMultiplier": {
    "placeholders": {"speed": {"type": "double"}}
  },

  "hoursLong": "{count, plural, =1{1 hour} other{{count} hours}}",
  "@hoursLong": {
    "placeholders": {"count": {"type": "int"}}
  },
  "minutesLong": "{count, plural, =1{1 minute} other{{count} minutes}}",
  "@minutesLong": {
    "placeholders": {"count": {"type": "int"}}
  },
  "secondsLong": "{count, plural, =1{1 second} other{{count} seconds}}",
  "@secondsLong": {
    "placeholders": {"count": {"type": "int"}}
  },
  "hoursMinutesCompact": "{hours}h {minutes}m",
  "@hoursMinutesCompact": {
    "placeholders": {
      "hours": {"type": "int"},
      "minutes": {"type": "int"}
    }
  },
  "minutesCompact": "{minutes} min",
  "@minutesCompact": {
    "placeholders": {"minutes": {"type": "int"}}
  },

  "noEpisodes": "No episodes",
  "noEpisodesDescription": "Check back later for new episodes",

  "download": "Download",
  "retryDownload": "Retry download",
  "downloading": "Downloading...",
  "downloaded": "Downloaded",

  "sleepTimer": "Sleep Timer",
  "customTime": "Custom time",
  "endOfEpisode": "End of episode",
  "cancelTimer": "Cancel timer",
  "timerActive": "Timer: {time} remaining",
  "@timerActive": {
    "placeholders": {"time": {"type": "String"}}
  },

  "fiveMinutes": "5 minutes",
  "tenMinutes": "10 minutes",
  "fifteenMinutes": "15 minutes",
  "thirtyMinutes": "30 minutes",
  "fortyFiveMinutes": "45 minutes",
  "oneHour": "1 hour",

  "minutesRemaining": "{count, plural, =1{1 minute remaining} other{{count} minutes remaining}}",
  "@minutesRemaining": {
    "placeholders": {"count": {"type": "int"}}
  },
  "hoursRemaining": "{count, plural, =1{1 hour remaining} other{{count} hours remaining}}",
  "@hoursRemaining": {
    "placeholders": {"count": {"type": "int"}}
  },
  "hoursMinutesRemaining": "{hours}h {minutes}m remaining",
  "@hoursMinutesRemaining": {
    "placeholders": {
      "hours": {"type": "int"},
      "minutes": {"type": "int"}
    }
  },

  "nowPlaying": "Now Playing",
  "upNext": "Up Next",
  "queue": "Queue",
  "addToQueue": "Add to queue",
  "removeFromQueue": "Remove from queue",
  "clearQueue": "Clear queue"
}

Conclusion

Audio app localization requires:

  1. Playback controls with translated labels
  2. Duration formatting per locale
  3. Content metadata in user's language
  4. Accessibility for screen readers
  5. Sleep timer with localized options

With comprehensive localization, your audio app will engage users worldwide.

Related Resources