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:
- Playback controls with translated labels
- Duration formatting per locale
- Content metadata in user's language
- Accessibility for screen readers
- Sleep timer with localized options
With comprehensive localization, your audio app will engage users worldwide.