# How to Add a Multi-Language Audio Guide to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A multi-language audio guide needs four pieces: a Supabase table storing audio URLs keyed by language code, just_audio for playback, a dio-powered offline download manager, and transcript text synced to the playback position. FlutterFlow builds the guide stop list and language selector visually; playback and downloads need a Custom Action Dart block. Expect 2–4 days with FlutterFlow, 2–4 weeks custom. Running costs start at $0–25/mo for small guide sets and scale with audio bandwidth and language count.

## What a Multi-Language Audio Guide Feature Actually Is

A multi-language audio guide lets users tap a stop on a list — a museum exhibit, a trail waypoint, a lesson chapter — and hear narrated commentary in their chosen language, with a synchronized transcript scrolling alongside. The audio files live in Supabase Storage, keyed by language code in a jsonb column. The app downloads them to the device so the guide works without connectivity, tracks which stops the user has completed, and lets them switch languages at any point without losing their place. The product decisions that matter most are whether guides are free or paywalled, how many languages you support at launch (each multiplies storage and bandwidth), and whether you trigger stop playback manually or automatically by GPS proximity.

## Anatomy of the Feature

Six components. FlutterFlow handles the UI layer visually; the audio engine and download manager require Custom Action Dart blocks. The data model is the linchpin — get the jsonb schema right up front and the rest snaps together.

- **Language selector** (ui): A Flutter DropdownButton or CupertinoPicker listing available languages pulled from the guide's available_languages text[] column in Supabase. Flag icons rendered with flutter_svg. Selected language code written to shared_preferences so the choice persists across screen navigations.
- **Audio player engine** (backend): just_audio (^0.9.x) handles HTTP streaming and local file playback. AudioPlayer.setUrl() for streaming; AudioPlayer.setFilePath() for downloaded files. ConcatenatingAudioSource enables playlist mode so auto-advance between stops works without re-wiring the player.
- **Guide stop list** (ui): Flutter ListView of stop cards showing thumbnail, stop number, title, duration chip, and download status icon. Tapping a card calls player.setUrl(audioUrl) with the URL from audio_urls[selectedLanguage] for that stop and begins playback. Built visually in FlutterFlow using a Backend Query on the guide_stops table.
- **Offline download manager** (backend): dio (^5.x) downloads audio files to getApplicationDocumentsDirectory() via path_provider. Download progress tracked as a double (0.0–1.0) in state. Completed paths saved to shared_preferences keyed by stop_id + language code. Before playback, the app checks for a local file first and falls back to the streaming URL.
- **Content data layer** (data): Supabase table guide_stops with audio_urls and transcript columns stored as jsonb objects keyed by ISO 639-1 language code ('en', 'es', 'fr'). A single Supabase query with .eq('guide_id', guideId).order('stop_number') fetches all stops for the guide in one call.
- **Transcript sync layer** (ui): StreamBuilder listening to just_audio's positionStream emits the current playback position as a Duration. The transcript text is split into timed segments (stored in the transcript jsonb as an array of {start_ms, end_ms, text} objects). A comparison against position.inMilliseconds highlights the active segment in the Flutter RichText or AnimatedContainer.

## Data model

Three tables cover guides, stops with multilingual content, and user progress. Run this in the Supabase SQL editor. The jsonb columns for audio_urls and transcript use ISO 639-1 language codes as keys.

```sql
create table public.audio_guides (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  category text,
  thumbnail_url text,
  available_languages text[] not null default '{}',
  is_published boolean not null default false,
  created_at timestamptz not null default now()
);

create table public.guide_stops (
  id uuid primary key default gen_random_uuid(),
  guide_id uuid references public.audio_guides(id) on delete cascade not null,
  stop_number int not null,
  title text not null,
  audio_urls jsonb not null default '{}',
  transcript jsonb not null default '{}',
  thumbnail_url text,
  lat decimal(9,6),
  lng decimal(9,6),
  duration_seconds int,
  created_at timestamptz not null default now()
);

create table public.user_progress (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  guide_id uuid references public.audio_guides(id) on delete cascade not null,
  last_stop_number int not null default 1,
  completed_at timestamptz,
  unique(user_id, guide_id)
);

alter table public.audio_guides enable row level security;
alter table public.guide_stops enable row level security;
alter table public.user_progress enable row level security;

create policy "Public can view published guides"
  on public.audio_guides for select
  using (is_published = true);

create policy "Public can view stops for published guides"
  on public.guide_stops for select
  using (
    exists (
      select 1 from public.audio_guides
      where id = guide_stops.guide_id
        and is_published = true
    )
  );

create policy "Users can read own progress"
  on public.user_progress for select
  using (auth.uid() = user_id);

create policy "Users can write own progress"
  on public.user_progress for insert
  with check (auth.uid() = user_id);

create policy "Users can update own progress"
  on public.user_progress for update
  using (auth.uid() = user_id);

create index guide_stops_guide_order_idx
  on public.guide_stops (guide_id, stop_number);
```

The unique constraint on user_progress(user_id, guide_id) enables upsert logic — call supabase.from('user_progress').upsert({...}, { onConflict: 'user_id,guide_id' }) on every stop completion without checking for existing rows first.

## Build paths

### Lovable — fit 1/10, Not applicable for mobile

Lovable generates a Vite/React web app. A multi-language mobile audio guide requires just_audio for native playback, dio for offline downloads, and iOS background audio entitlements — none of which are available in a web context. Use FlutterFlow or custom Flutter for this feature.

1. Use FlutterFlow for the mobile audio guide as described in the primary build path above
2. If a web audio guide is a separate product requirement, Lovable can build a browser-based version using the HTML5 Audio API against the same Supabase Storage audio URLs

Starter prompt:

```
Build a web audio guide player. Fetch audio_guides from Supabase and show a guide selector. On guide select, fetch guide_stops filtered by guide_id ordered by stop_number. Add a language selector DropdownButton populated from the guide's available_languages text[] column; store the choice in localStorage so it persists on page reload. Display each stop as a card with stop number, title, and duration. On stop card click, load the audio URL from audio_urls[selectedLanguage] into an HTML5 Audio element and play it. Player controls: play/pause button, seek slider showing current position and total duration, previous/next stop buttons. Auto-advance to the next stop when audio ends. Show the transcript text from transcript[selectedLanguage] below the player; highlight the current paragraph using the audio timeupdate event to compare currentTime against stored segment timestamps. Show 'Not available in this language' if audio_urls[selectedLanguage] is null. Save progress to Supabase user_progress table (user_id, guide_id, last_stop_number) with RLS on every stop completion.
```

Limitations:

- Lovable outputs a Vite/React web app — the mobile audio guide requires FlutterFlow or custom Flutter for offline downloads, background playback, and native iOS/Android audio sessions
- The HTML5 Audio API does not support offline playback — users on the web version must have connectivity at all times
- iOS Safari limits background audio playback in browser tabs; the web version will pause when the screen locks, unlike the native Flutter version with UIBackgroundModes audio

### Flutterflow — fit 4/10, 2–4 days

FlutterFlow handles guide stop list, language selector DropdownButton, and Supabase Backend Queries visually. just_audio playback and the dio download manager each require one Custom Action block, but the rest is drag-and-drop.

1. In FlutterFlow, create a new page 'AudioGuide' and add a Backend Query on guide_stops filtered by guide_id and ordered by stop_number
2. Add a DropdownButton populated from the guide's available_languages field; store the selected value in FlutterFlow App State so it persists across screens
3. Create a Custom Action 'PlayAudioStop' using just_audio: accept audio_url string and file_path string parameters; check for local file first with File(filePath).existsSync(), fall back to URL; call player.setUrl() or player.setFilePath() accordingly
4. Create a Custom Action 'DownloadAudioStop' using dio: download to getApplicationDocumentsDirectory() path; emit progress via a callback; save the path to shared_preferences on completion
5. In FlutterFlow Project Settings under iOS → Info.plist, add UIBackgroundModes with value 'audio' — without this, playback silences on screen lock
6. Test on a real physical device via the FlutterFlow mobile preview app; the web preview does not support audio playback or file system access

Limitations:

- Flutter staggered masonry layout and custom transcript sync animations are not available in the FlutterFlow visual builder — they require Custom Widgets
- The dio download manager Custom Action requires approximately 50 lines of Dart that FlutterFlow cannot generate — a developer needs to write this block once
- FlutterFlow's visual builder has no built-in audio position stream for transcript sync; this also requires a Custom Widget using a StreamBuilder on just_audio's positionStream
- Testing offline mode requires a physical device with airplane mode — FlutterFlow's preview environment does not simulate offline storage

### Custom — fit 5/10, 2–4 weeks

Full native Flutter build gives complete control over background audio entitlements, offline sync with WorkManager (Android) and BGTaskScheduler (iOS), and GPS geofencing for auto-play triggers.

1. Set up iOS background audio capability in Xcode: Signing & Capabilities → Background Modes → check Audio, AirPlay, and Picture in Picture
2. Implement just_audio with audio_session package for proper iOS interruption handling (phone calls, Siri) and Android audio focus management
3. Build the download manager with WorkManager (Android) and BGTaskScheduler (iOS) for reliable background downloads that survive app suspension
4. Add geolocator and geofence_foreground_service packages for GPS-triggered auto-play when users physically approach a guide stop

Limitations:

- iOS background audio entitlements and geofencing require Apple Developer account and device provisioning — cannot be tested in a simulator for background modes
- Full timeline of 2–4 weeks assumes prior Flutter experience; audio session management and background task scheduling are non-trivial to get right across both platforms

## Gotchas

- **Audio silences the moment the screen locks on iOS** — On iOS, audio stops when the screen locks unless the app declares the audio background mode. just_audio handles the playback session correctly, but without UIBackgroundModes audio in Info.plist, iOS suspends the app and kills the audio. This manifests as audio stopping exactly when the screen lock timeout fires — users report it as 'the app keeps pausing'. Fix: In FlutterFlow, add UIBackgroundModes with value 'audio' in Project Settings → iOS → Info.plist entries. For custom builds, enable Background Modes → Audio in Xcode Signing & Capabilities. Also add the audio_session package and call AudioSession.instance.configure(AudioSessionConfiguration.speech()) on app start to handle phone call interruptions gracefully.
- **just_audio silently fails on HTTP URLs on Android 9+** — Android 9 (API 28) and above block cleartext HTTP traffic by default. If Supabase Storage URLs are HTTP instead of HTTPS — which can happen with custom domain configurations or local dev tunnels — just_audio fails silently: the player state shows 'loading' indefinitely with no error event. Fix: Confirm all audio URLs use HTTPS. In FlutterFlow Project Settings, verify the Supabase URL is https://. For any testing environment, ensure the URL scheme is https:// before passing it to player.setUrl(). Never ship with android:usesCleartextTraffic='true' in the production AndroidManifest.
- **Language selector resets to default on screen navigation** — Storing the selected language in a page-local Flutter state variable or FlutterFlow page state causes it to reset when the user taps into a stop detail screen and presses back. The user returns to the guide list to find English selected again even though they chose Spanish. This is one of the most common complaints in audio guide apps built with FlutterFlow. Fix: Store the selected language code in FlutterFlow App State (not page state) or in shared_preferences via a Custom Action. Read it on initState of every screen that needs it. In the Custom Action for playback, always read the language code from App State rather than receiving it as a parameter that could be stale.
- **Supabase jsonb audio_urls returns null for missing languages** — The audio_urls jsonb column returns a Map<String, dynamic> in Dart. If a guide stop hasn't been recorded in the selected language, audio_urls['fr'] returns null. Passing null to player.setUrl() throws 'source is null' — an opaque crash that doesn't tell the user what actually went wrong. Fix: Always null-check before playback: final audioUrl = (stop['audio_urls'] as Map?)?[selectedLanguage] as String?. If audioUrl is null, disable the play button and show a localized message like 'Not available in French — tap to switch language'. This pattern catches missing languages gracefully instead of crashing.
- **Offline downloads bloat device storage with no cleanup path** — Users who download guides in multiple languages can accumulate 500 MB+ of audio files on their device over time. Because the download paths are stored in shared_preferences with no expiry, there's no mechanism to clean them up. This becomes a 1-star review: 'this app ate 1 GB of my storage'. Fix: Add a Downloads screen listing each downloaded stop with its file size (File(path).lengthSync()) and a delete button that calls File(path).deleteSync() and removes the key from shared_preferences. Show total download size in the header. Consider adding a 'Delete all downloads for this guide' option so users can free space in one tap.

## Best practices

- Store audio_urls as a jsonb object keyed by ISO 639-1 codes ('en', 'es', 'fr') from day one — adding languages later is a data entry task, not a schema migration
- Check for a local file before streaming on every play action — users in underground museums or airplanes will lose connectivity mid-guide
- Show download size next to each language option before the user starts downloading — a 5-language pack can be 75 MB; set expectations
- Persist the selected language in shared_preferences or App State at the app level, not page level — language choice should outlive any navigation stack pop
- Handle the 'not available in this language' state explicitly with a message and a language-switch shortcut rather than a silent empty player
- Save user_progress on every stop completion, not just at the end of the guide — guides are interrupted constantly and users expect to resume exactly where they left off
- Use ConcatenatingAudioSource for the stop playlist so just_audio can buffer the next stop while the current one plays — eliminates the gap between stops on auto-advance

## Frequently asked questions

### How do I store audio files for multiple languages in Supabase?

Store audio files in Supabase Storage in a folder structure like /guides/{guide_id}/{stop_id}/{language_code}.mp3. In the guide_stops table, keep a jsonb column called audio_urls with entries like {'en': 'https://...en.mp3', 'es': 'https://...es.mp3'}. Query the column in Flutter and access the URL with audioUrls[selectedLanguage]. This lets you add languages without changing the schema or the app code.

### Can audio guides work offline on mobile?

Yes, and for museum and travel guides, offline is usually a requirement — many locations have poor connectivity. Use the dio package to download audio files to getApplicationDocumentsDirectory() before the user enters the venue. Store the local file path in shared_preferences keyed by stop ID and language code. On playback, check for the local file first with File(path).existsSync() and fall back to the streaming URL if not found.

### How do I sync transcript text to audio playback position in Flutter?

Store transcripts as a jsonb array of timed segments: [{start_ms: 0, end_ms: 4200, text: 'Welcome to gallery one.'}, ...]. In Flutter, wrap your transcript widget in a StreamBuilder listening to just_audio's positionStream. On each position update, find the segment where position.inMilliseconds is between start_ms and end_ms and apply highlighting to that element. For launch without timed transcripts, display the full text statically — users still benefit from the text, and you can add timing data later.

### What Flutter package is best for audio playback in a guide app?

just_audio (^0.9.x) is the clear choice for guide apps. It supports HTTP streaming and local file playback from the same API, handles background audio correctly with the audio_session package, and provides positionStream for transcript sync. ConcatenatingAudioSource enables playlist mode for smooth auto-advance between stops. The main alternative, audioplayers, is simpler but lacks the streaming and playlist features a multi-stop guide needs.

### How do I auto-play the next stop when the current one finishes?

The cleanest approach is ConcatenatingAudioSource: create an AudioSource for each stop in order and pass the full list to player.setAudioSource(). just_audio advances automatically. For more control — pausing between stops to show a summary screen, for example — listen to player.playerStateStream and watch for PlayerState.completed, then call player.setUrl(nextStop.audioUrl) and player.play() in the handler.

### How do I handle language selection persisting across screens?

Store the selected language code in FlutterFlow App State (not page state) or in shared_preferences. In shared_preferences, write the code on every change with prefs.setString('selected_language', code) and read it on initState of any screen that needs it. In FlutterFlow, use a global App State variable and update it from the DropdownButton's onChange action — this survives any navigation stack pop.

### Can I use AI to generate audio guide voiceovers in multiple languages?

Yes. ElevenLabs is the most practical option: write your stop scripts in the source language, translate them (DeepL or GPT-4), then generate audio via the ElevenLabs API for each language. The Starter plan ($5/mo) covers 30,000 characters — enough for roughly 10 stops at 200 words each across 3 languages. Store the generated MP3s in Supabase Storage. The audio quality is good enough for informational guides; for flagship cultural institutions, recorded human narration still wins.

### How much does it cost to store audio files for 10 stops in 5 languages?

At roughly 3 MB per audio file: 10 stops × 5 languages = 50 files = 150 MB of storage. That fits comfortably within Supabase's free tier (1 GB storage). Bandwidth is where cost appears — if 1,000 users each stream rather than download, that's roughly 150 GB of bandwidth per cycle through all stops, pushing you toward Supabase Pro ($25/mo). Encouraging offline download actually reduces bandwidth costs because each user downloads once rather than streaming repeatedly.

---

Source: https://www.rapidevelopers.com/app-features/multi-language-audio-guide
© RapidDev — https://www.rapidevelopers.com/app-features/multi-language-audio-guide
