Feature spec
IntermediateCategory
media-content
Build with AI
2–4 days with FlutterFlow + manual audio wiring
Custom build
2–4 weeks custom dev
Running cost
$0–25/mo up to ~50 audio files; $75–200/mo at 10K users with CDN bandwidth
Works on
Everything it takes to ship a Multi-Language Audio Guide — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Language selector with flag icons shows every available language before the guide starts, not buried in settings
- Offline download indicator per stop — users need to know what they've downloaded before entering a museum or going underground
- Transcript text scrolls in sync with audio playback, with the current sentence highlighted
- Previous and next stop buttons with auto-advance option at clip end so hands-free listening works
- Download progress bar with cancel option — a 50 MB language pack takes time on a slow connection
- Clear 'not available in this language' state when a specific stop hasn't been recorded in the selected language
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
UIA 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.
Note: Store the selected language in shared_preferences or FlutterFlow App State — not a page-local variable — or it resets every time the user navigates to a stop detail screen and back.
Audio player engine
Backendjust_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.
Note: just_audio requires the iOS UIBackgroundModes audio entry in Info.plist or audio silences the moment the screen locks. Add this in FlutterFlow Project Settings before testing on a real device.
Guide stop list
UIFlutter 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.
Note: Always null-check the jsonb field — audio_urls?[selectedLanguage] — before passing to the player. A missing language code returns null and crashes the player with an unhelpful 'source is null' error.
Offline download manager
Backenddio (^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.
Note: This component cannot be built visually in FlutterFlow — it requires a Custom Action of approximately 50 lines of Dart. The payoff is full offline capability without a CDN dependency.
Content data layer
DataSupabase 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.
Note: Store lat/lng on each stop row even if GPS auto-play isn't in v1 — retrofitting geolocation later requires a migration, and the columns cost nothing unused.
Transcript sync layer
UIStreamBuilder 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.
Note: If transcripts aren't timed at launch, display the full transcript as static text that scrolls independently — users still find it useful, and you can add timed sync later without changing the data model.
The 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.
1create table public.audio_guides (2 id uuid primary key default gen_random_uuid(),3 name text not null,4 category text,5 thumbnail_url text,6 available_languages text[] not null default '{}',7 is_published boolean not null default false,8 created_at timestamptz not null default now()9);1011create table public.guide_stops (12 id uuid primary key default gen_random_uuid(),13 guide_id uuid references public.audio_guides(id) on delete cascade not null,14 stop_number int not null,15 title text not null,16 audio_urls jsonb not null default '{}',17 transcript jsonb not null default '{}',18 thumbnail_url text,19 lat decimal(9,6),20 lng decimal(9,6),21 duration_seconds int,22 created_at timestamptz not null default now()23);2425create table public.user_progress (26 id uuid primary key default gen_random_uuid(),27 user_id uuid references auth.users(id) on delete cascade not null,28 guide_id uuid references public.audio_guides(id) on delete cascade not null,29 last_stop_number int not null default 1,30 completed_at timestamptz,31 unique(user_id, guide_id)32);3334alter table public.audio_guides enable row level security;35alter table public.guide_stops enable row level security;36alter table public.user_progress enable row level security;3738create policy "Public can view published guides"39 on public.audio_guides for select40 using (is_published = true);4142create policy "Public can view stops for published guides"43 on public.guide_stops for select44 using (45 exists (46 select 1 from public.audio_guides47 where id = guide_stops.guide_id48 and is_published = true49 )50 );5152create policy "Users can read own progress"53 on public.user_progress for select54 using (auth.uid() = user_id);5556create policy "Users can write own progress"57 on public.user_progress for insert58 with check (auth.uid() = user_id);5960create policy "Users can update own progress"61 on public.user_progress for update62 using (auth.uid() = user_id);6364create index guide_stops_guide_order_idx65 on public.guide_stops (guide_id, stop_number);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Set up iOS background audio capability in Xcode: Signing & Capabilities → Background Modes → check Audio, AirPlay, and Picture in Picture
- 2Implement just_audio with audio_session package for proper iOS interruption handling (phone calls, Siri) and Android audio focus management
- 3Build the download manager with WorkManager (Android) and BGTaskScheduler (iOS) for reliable background downloads that survive app suspension
- 4Add geolocator and geofence_foreground_service packages for GPS-triggered auto-play when users physically approach a guide stop
Where this path bites
- 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
Third-party services you'll need
The guide runs on Supabase Storage for audio files. ElevenLabs is optional for AI-generated voiceovers instead of recorded narration:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Storage | Hosts audio files (2–5 MB each per language) and serves them over HTTPS for streaming and download | 1 GB storage, 2 GB bandwidth | $25/mo (Pro) includes 100 GB storage + 200 GB bandwidth (approx) |
| Supabase Database | guide_stops, audio_guides, user_progress tables with jsonb multilingual content | 500 MB storage, 2 projects | $25/mo (Pro) includes 8 GB (approx) |
| ElevenLabs | Optional AI voiceover synthesis per language — generate audio guide narration from text scripts without recording studios | 10,000 characters/mo | Starter $5/mo (30,000 chars); Creator $22/mo (100,000 chars) (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers ~50 audio files at 3 MB each (150 MB well within free storage). Small bandwidth at 100 users. ElevenLabs free tier sufficient for 1–2 language scripts at launch.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Audio silences the moment the screen locks on iOS
Symptom: 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+
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
FlutterFlow with Custom Actions covers the core guide experience. These requirements push past what visual builders handle:
- GPS-triggered auto-play: automatically start the audio when a user walks within 30 meters of a guide stop using geofencing — requires WorkManager (Android) and BGTaskScheduler (iOS) for background location monitoring
- AI-generated voiceover on demand per language using ElevenLabs voice cloning with a custom institutional voice — involves a server-side generation pipeline, not a one-time ElevenLabs export
- Time-limited signed URLs for paid guide protection — audio URLs that expire after 24 hours so downloaded files can't be redistributed, requiring a custom URL rotation mechanism
- Real-time live audio commentary: a human guide speaking live and streaming to multiple app users simultaneously, which requires WebRTC or HLS streaming infrastructure far beyond static file playback
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds a multi-language audio guide into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.