Cross-platform media library extension for Flet — query albums, page through photos/videos/audio, generate thumbnails, save, delete, move and rename media on Android and iOS.
A high-performance Flet service extension for querying, displaying, saving, mutating, and monitoring the device media library from Python on Android and iOS.
The Python API is Flet-native, powered on Flutter by photo_manager for cross-platform querying, thumbnails, and saving photos/videos directly to public directories (DCIM/, Pictures/, Movies/), augmented with custom native Android Kotlin implementations for saving audio recordings directly to Music/, renaming assets, and relative directory moving.
Current Version: 1.1.2 | PyPI Package: flet-media-library
Developed & Maintained by: Fazi Gondal
Try the App: Download the pre-built Media Library Demo APK from GitHub Releases
DCIM/ and Pictures/, videos to Movies/ and DCIM/, and audio recordings directly to Music/.MANAGE_EXTERNAL_STORAGE.photo_manager: audio saving to Music/, in-place file renaming, and relative folder moving.ft.Image use, plus get_thumbnail_path() for large galleries (cached file path, no Base64 over the bridge).READ_MEDIA_*).get_capabilities() reports what the current platform supports (supports_audio_save, supports_move, supports_rename, etc.).Camera, Screenshots, Download, Music, WhatsApp, etc.).limit, offset, has_more).PermissionRequiredError, UnsupportedError, AssetNotFoundError, etc.).flet-media-library saves media directly into standard Android and iOS public directories via native platform MediaStore and PhotoKit APIs:
| Operation | Public Storage Destination | Under the Hood Implementation | Platform Support |
|---|---|---|---|
Save Photo (save_image) |
DCIM/ or Pictures/ |
photo_manager (saveImageWithPath) |
Android & iOS |
Save Video (save_video) |
Movies/ or DCIM/ |
photo_manager (saveVideo) |
Android & iOS |
Save Audio (save_audio) |
Music/ (e.g. Music/Recordings/) |
Custom Native Kotlin (MediaStore.Audio) |
Android Only |
Rename Asset (rename_asset) |
Renames display name in-place | Custom Native Kotlin (MediaStore update) |
Android Only |
Move Asset (move_asset) |
Moves between public albums/folders | Custom Native Kotlin (RELATIVE_PATH update) |
Android Only |
Delete Assets (delete_assets) |
Deletes from system gallery | photo_manager (deleteWithIds) |
Android & iOS |
[!IMPORTANT]
Custom Kotlin Implementation on Android:
Upstream Flutterphoto_managerlacks APIs to save audio, rename assets, or move assets between folders. To provide these essential features on Android without requiring dangerous all-files access (MANAGE_EXTERNAL_STORAGE),flet-media-libraryincludes custom native Android Kotlin code (FletMediaLibraryPlugin.kt).
Consequently,save_audio,rename_asset, andmove_assetare Android-only features. On iOS, calling these methods cleanly raisesUnsupportedError.
flet-media-library strictly follows the principle of least privilege. You do not need (and should never request) broad filesystem access or MANAGE_EXTERNAL_STORAGE for media operations.
| Android API Version | Permission Mechanism | Purpose |
|---|---|---|
| Android 6 – 12 (API 23–32) | READ_EXTERNAL_STORAGE (maxSdkVersion=32) |
Reads shared media library |
| Android 13+ (API 33+) | READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO |
Granular permission for specific media types |
| Android 14+ (API 34+) | READ_MEDIA_VISUAL_USER_SELECTED |
User grants access only to selected items |
| iOS 14+ | PhotoKit Authorization | Full, limited (partial), or denied access |
| Saving Media | MediaStore / PhotoKit Insert APIs | No broad write permission needed on modern systems |
This package automatically bundles the following permissions into your Android build manifest:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
When compiling for iOS (flet build ipa), declare usage descriptions for the photo library:
<key>NSPhotoLibraryUsageDescription</key>
<string>This app requires access to your photo library to browse and select media.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app requires permission to save photos and videos to your gallery.</string>
pip install flet-media-library
# or using uv
uv add flet-media-library
pyproject.toml[project]
dependencies = [
"flet>=1.0.0",
"flet-media-library>=1.1.2",
]
import flet as ft
from flet_media_library import MediaLibrary
async def main(page: ft.Page):
media = MediaLibrary()
page.services.append(media)
page.update()
# Request permissions for images and videos
status = await media.request_permissions(["image", "video"])
if not status.all_granted and not status.any_limited:
page.add(ft.Text("Media permission was denied!"))
return
# Query latest 20 photos
page_data = await media.get_assets(media_type="image", limit=20)
grid = ft.GridView(expand=True, max_extent=120, spacing=5, run_spacing=5)
for asset in page_data.items:
# Load thumbnail as base64
thumb_b64 = await media.get_thumbnail(asset.id, width=120, height=120)
grid.controls.append(
ft.Image(src_base64=thumb_b64, fit=ft.BoxFit.COVER, border_radius=4)
)
page.add(grid)
if __name__ == "__main__":
ft.run(main)
You can test flet-media-library immediately on your physical Android phone without setting up Flutter or Android development tools.
Pre-compiled, ready-to-sideload Android APKs (.apk) are automatically compiled and attached to every GitHub release:
👉 Download the latest Demo APK from GitHub Releases
flet-video / ExoPlayer).flet-camera) and WAV audio recorder (flet-audio-recorder PCM streaming) saving directly to gallery and Music/FletMediaLibrary.For instructions on building the demo from source, see examples/media_library_demo/README.md.
MediaLibrary inherits from ft.Service. It must be added to page.services:
from flet_media_library import MediaLibrary
media = MediaLibrary()
page.services.append(media)
page.update()
check_permissions(media_types: list[str] | None = None) -> MediaPermissionStatusChecks the current permission status without prompting the user.
media_types — List of types to check: ["image", "video", "audio"] (defaults to all).MediaPermissionStatus with a per-type states map. On Android 13+, image/video/audio can differ (granular READ_MEDIA_*).status = await media.check_permissions(["image", "video", "audio"])
print("Images:", status["image"]) # granted | limited | denied | ...
print("Videos:", status["video"])
print("Audio:", status["audio"])
print("can_request:", status.can_request)
request_permissions(media_types: list[str] | None = None) -> MediaPermissionStatusPrompts the OS system permission dialog requesting access for the specified media types, then re-checks each type so the returned map reflects actual outcomes.
media_types — ["image", "video", "audio"] (request only what you need).MediaPermissionStatus.status = await media.request_permissions(["image", "video", "audio"])
if status.all_granted:
print("Full media library access granted")
elif status.any_limited:
print("User granted limited/partial media access")
present_limited(media_types: list[str] | None = None) -> NoneRe-opens the system limited-picker on iOS 14+ or Android 14+ so the user can select additional photos/videos without having to grant full library access.
await media.present_limited(["image", "video"])
open_settings() -> NoneNavigates the user directly to the application's system settings screen (useful when permission is denied_forever).
await media.open_settings()
get_albums(media_type: str = "all") -> list[MediaAlbum]Fetches albums/folders containing the specified media type.
media_type — "all", "image", "video", or "audio".list[MediaAlbum].albums = await media.get_albums(media_type="image")
for album in albums:
print(f"Album: {album.name} (ID: {album.id}) - {album.asset_count} items")
get_assets(...) -> MediaAssetPageQueries assets with pagination, optional album filtering, and sorting.
async def get_assets(
media_type: str = "all", # "all" | "image" | "video" | "audio"
album: str | None = None, # Album ID from get_albums(), or None for root
mime_type: str | None = None, # Exact MIME filter (e.g. "video/mp4", Android only)
limit: int = 50, # 1 to 500 items per page
offset: int = 0, # Item offset to skip (re-query from 0 after change events)
sort_by: str = "date_added", # "date_added", "date_modified", "display_name", "size", "duration"
sort_order: str = "desc", # "desc" | "asc"
min_date_added: int | None = None, # Inclusive unix seconds (global queries only)
max_date_added: int | None = None, # Inclusive unix seconds (global queries only)
) -> MediaAssetPage
# Query page 1 (first 30 videos sorted newest first)
page1 = await media.get_assets(
media_type="video",
limit=30,
offset=0,
sort_by="date_added",
sort_order="desc",
)
print(f"Total: {page1.total}, Loaded: {len(page1.items)}, Has more: {page1.has_more}")
# Query next page
if page1.has_more:
page2 = await media.get_assets(media_type="video", limit=30, offset=30)
get_asset(asset_id: str) -> MediaAssetRetrieves detailed metadata for a single specific asset by its ID.
asset = await media.get_asset("1000000032")
print(f"{asset.display_name} - {asset.width}x{asset.height} - {asset.size} bytes")
get_thumbnail(asset_id: str, width: int = 200, height: int = 200, quality: int = 90) -> strGenerates a base64-encoded JPEG thumbnail for an image or a video frame.
asset_id, width, height, quality (1–100).ft.Image(src_base64=...).get_thumbnail_path when rendering many items (gallery grids).thumb_b64 = await media.get_thumbnail(asset.id, width=150, height=150, quality=85)
image_ctrl = ft.Image(src_base64=thumb_b64, width=150, height=150)
get_thumbnail_path(asset_id: str, width: int = 200, height: int = 200, quality: int = 90) -> strWrites a JPEG thumbnail to a stable local cache file and returns the filesystem path.
asset_id, size, and quality; cleared by clear_file_cache().ft.Image(src=path) on mobile.path = await media.get_thumbnail_path(asset.id, width=160, height=160)
image_ctrl = ft.Image(src=path, width=160, height=160, fit=ft.BoxFit.COVER)
Saves local files directly into the platform media gallery using platform MediaStore and PhotoKit insert pipelines. No broad storage permissions required.
Destination folder naming: prefer keyword
relative_path=(e.g."Pictures/MyApp").album=remains as a backward-compatible alias for the same value.
save_image(file_path: str, file_name: str | None = None, *, relative_path: str | None = None, album: str | None = None) -> MediaAssetSaves an image file directly into public gallery storage (Pictures/ or DCIM/).
Pictures/<Subfolder> (e.g. relative_path="Pictures/MyApp")DCIM/<Subfolder> (e.g. relative_path="DCIM/Camera")photo_manager (saveImageWithPath).file_path: Absolute path to source image (e.g. app scratch file or downloaded photo).file_name: Optional target filename (e.g. "snapshot_2026.jpg").relative_path: Preferred destination folder under public storage.album: Legacy alias for relative_path.asset = await media.save_image(
"/data/user/0/com.app/cache/photo.jpg",
file_name="snapshot_2026.jpg",
relative_path="Pictures/MyCameraApp",
)
print("Saved image ID:", asset.id)
save_video(file_path: str, file_name: str | None = None, *, relative_path: str | None = None, album: str | None = None) -> MediaAssetSaves a video file directly into public gallery storage (Movies/ or DCIM/).
Movies/<Subfolder> (e.g. album="Movies/MyVideoApp")DCIM/<Subfolder> (e.g. album="DCIM/Camera")photo_manager (saveVideo).file_path: Absolute path to source video file.file_name: Optional target filename (e.g. "clip.mp4").album: Optional public folder name (defaults to "Movies/FletMediaLibrary").asset = await media.save_video(
"/path/to/recording.mp4",
file_name="clip.mp4",
album="Movies/MyCameraApp",
)
save_audio(file_path: str, file_name: str | None = None, *, relative_path: str | None = None, album: str | None = None) -> MediaAssetSaves an audio file directly into the device's public Music/ folder.
Music/<Subfolder> (e.g. album="Music/Recordings" or album="Music/MyPodcasts").FletMediaLibraryPlugin.kt writing directly to MediaStore.Audio.Media). Upstream photo_manager does not support saving audio files.UnsupportedError on iOS).file_path: Absolute path to source audio file (e.g. .m4a, .mp3, .wav, .aac).file_name: Target filename (e.g. "recording_01.m4a").album: Target subfolder within Music (defaults to "Music/FletMediaLibrary").asset = await media.save_audio(
"/path/to/voice_note.m4a",
file_name="recording_01.m4a",
album="Music/Recordings",
)
print(f"Audio indexed in Music/Recordings: {asset.display_name} (ID: {asset.id})")
delete_asset(asset_id: str) -> boolDeletes a single asset from the device gallery.
photo_manager (deleteWithIds).True if successfully deleted, False if user cancelled system dialog.ok = await media.delete_asset(asset.id)
if ok:
print("Asset deleted successfully")
delete_assets(asset_ids: list[str]) -> list[str]Batch deletes multiple assets in a single native system confirmation.
photo_manager (deleteWithIds).deleted_ids = await media.delete_assets(["id1", "id2", "id3"])
print(f"Deleted {len(deleted_ids)} items")
rename_asset(asset_id: str, new_name: str) -> boolRenames an asset's display name and filename directly in the device MediaStore.
FletMediaLibraryPlugin.kt). Upstream photo_manager does not support renaming assets.UnsupportedError on iOS). On Android 11+, the OS may present a native consent dialog for non-owned media.asset_id: The MediaStore ID of the item.new_name: New filename with extension (e.g. "vacation_sunset.jpg").ok = await media.rename_asset(asset.id, "vacation_sunset.jpg")
move_asset(asset_id: str, target_relative_path: str) -> boolMoves an asset between public folders (e.g., moving from DCIM/Camera to Pictures/Archive or Movies/Archive).
RELATIVE_PATH MediaStore update). Upstream photo_manager does not support folder relocation.UnsupportedError on iOS or older Android versions).asset_id: The MediaStore ID of the item.target_relative_path: Target public relative path (e.g. "Pictures/Archive").ok = await media.move_asset(asset.id, "Pictures/Archive")
if ok:
print("Moved to archive folder")
copy_asset(asset_id: str, target_album: str) -> MediaAssetCopies an asset into a target album. Platform support varies:
UnsupportedError.Subscribe to system media library changes (such as when new photos are taken by the camera or downloaded).
def on_media_changed(e: ft.ControlEvent):
event = e.data # MediaChangeEvent
print(f"Change detected: type={event.change_type}, id={event.asset_id}")
media.on_change = on_media_changed
await media.start_change_notify()
# When finished or exiting screen:
await media.stop_change_notify()
get_capabilities() -> dictReturns flags so apps do not hard-code platform gaps.
| Key | Type | Meaning |
|---|---|---|
platform |
str |
"android", "ios", or other |
supports_audio_save |
bool |
save_audio available |
supports_move |
bool |
move_asset available |
supports_rename |
bool |
rename_asset available |
supports_copy |
bool |
copy_asset available |
supports_mime_filter |
bool |
MIME filter on get_assets |
supports_limited_access |
bool |
Limited picker (iOS 14+ / Android 14+) |
supports_thumbnail_path |
bool |
get_thumbnail_path available |
supports_change_notify |
bool |
Change notifications |
android_sdk |
int |
SDK level, or 0 if not Android |
caps = await media.get_capabilities()
if caps.get("supports_audio_save"):
await media.save_audio(path, file_name="note.m4a", relative_path="Music/Recordings")
clear_file_cache() -> NoneClears internal thumbnail caches (including files created by get_thumbnail_path) and plugin file caches.
await media.clear_file_cache()
MediaAssetRepresents a single media item:
| Field | Type | Description |
|---|---|---|
id |
str |
Platform-unique asset identifier |
display_name |
str |
File name including extension (e.g. IMG_001.jpg) |
mime_type |
str |
MIME type (e.g. image/jpeg, video/mp4, audio/mp4) |
media_type |
str |
Broad type: "image", "video", or "audio" |
size |
int |
Size in bytes |
width |
int |
Pixel width (0 for audio) |
height |
int |
Pixel height (0 for audio) |
duration_ms |
int |
Duration in milliseconds (0 for images) |
date_added |
int |
Unix timestamp (seconds) when added to library |
date_modified |
int |
Unix timestamp (seconds) when last modified |
orientation |
int |
EXIF orientation angle (0, 90, 180, 270) |
album_id |
str |
Containing album identifier |
album_name |
str |
Containing album name |
relative_path |
str |
Relative directory (e.g. DCIM/Camera/) |
source_uri |
str |
System URI (content://... on Android, ph://... on iOS) |
MediaAlbumRepresents an album, bucket, or folder:
| Field | Type | Description |
|---|---|---|
id |
str |
Album unique identifier |
name |
str |
User-facing album title (e.g. Camera, Screenshots) |
asset_count |
int |
Count of media items in this album |
media_types |
list[str] |
Media types present in album (["image", "video"]) |
is_all |
bool |
True if this is the "Recent" / "All Media" collection |
is_system_album |
bool |
True for OS-managed collections |
platform_identifier |
str |
Native platform identifier |
MediaPermissionStatusSnapshot of permission states:
| Property / Method | Type | Description |
|---|---|---|
states |
dict[str, str] |
Maps media type to status (granted, limited, denied, denied_forever, restricted, unknown) |
status[media_type] |
str |
Shortcut for accessing status by key (e.g. status["image"]) |
all_granted |
bool |
True if all requested types are "granted" |
any_limited |
bool |
True if any requested type is "limited" |
can_request |
bool |
True if system dialog can still be requested |
MediaAssetPageResult of a paginated get_assets query:
| Field | Type | Description |
|---|---|---|
items |
list[MediaAsset] |
List of MediaAsset items for this page |
total |
int |
Total count matching filter |
offset |
int |
Current query offset |
limit |
int |
Items per page |
has_more |
bool |
True if more items are available |
All exceptions inherit from MediaLibraryError:
MediaLibraryError
├── PermissionRequiredError # Operation attempted before permissions were granted
├── PermissionDeniedError # User actively denied permission
├── UnsupportedError # Method unsupported on this OS or OS version
├── AssetNotFoundError # Target asset ID does not exist
├── AlbumNotFoundError # Target album ID does not exist
├── InvalidArgumentError # Bad arguments (e.g. limit > 500, negative offset)
└── PlatformError # Underlying native platform exception
import flet as ft
from flet_media_library import MediaLibrary, PermissionRequiredError
async def main(page: ft.Page):
media = MediaLibrary()
page.services.append(media)
page.update()
status = await media.request_permissions(["image", "video"])
if not (status.all_granted or status.any_limited):
page.add(ft.Text("Permissions denied. Cannot browse media."))
return
grid = ft.GridView(expand=True, max_extent=110, spacing=4, run_spacing=4)
page.add(grid)
asset_page = await media.get_assets(media_type="image", limit=40)
for asset in asset_page.items:
thumb = await media.get_thumbnail(asset.id, width=110, height=110)
grid.controls.append(
ft.Image(src_base64=thumb, fit=ft.BoxFit.COVER, border_radius=4)
)
page.update()
ft.run(main)
import flet as ft
from flet_media_library import MediaLibrary
async def save_captured_media(media: MediaLibrary, photo_file: str, video_file: str, audio_file: str):
# 1. Save photo directly to public Pictures/ or DCIM/ (cross-platform via photo_manager)
photo_asset = await media.save_image(
photo_file,
file_name="snapshot_2026.jpg",
album="Pictures/MyCameraApp", # or "DCIM/Camera"
)
print(f"Photo saved into Pictures: {photo_asset.display_name} (ID: {photo_asset.id})")
# 2. Save video directly to public Movies/ or DCIM/ (cross-platform via photo_manager)
video_asset = await media.save_video(
video_file,
file_name="clip_2026.mp4",
album="Movies/MyCameraApp", # or "DCIM/Camera"
)
print(f"Video saved into Movies: {video_asset.display_name} (ID: {video_asset.id})")
# 3. Save audio directly to public Music/ (Android-only via custom Kotlin engine)
audio_asset = await media.save_audio(
audio_file,
file_name="voice_note.m4a",
)
print(f"Audio indexed into Music: {audio_asset.display_name} (ID: {audio_asset.id})")
from flet_media_library import MediaLibrary, UnsupportedError
async def organize_media(media: MediaLibrary, asset_id: str):
try:
# Rename file
renamed = await media.rename_asset(asset_id, "family_vacation_2026.jpg")
print("Renamed:", renamed)
# Move to Archive folder
moved = await media.move_asset(asset_id, "Pictures/Archive")
print("Moved:", moved)
except UnsupportedError as err:
print("Operation not supported on this platform/OS version:", err)
async def watch_gallery(page: ft.Page, media: MediaLibrary):
async def on_change(e):
ev = e.data
print(f"Library updated! Type: {ev.change_type}, Asset: {ev.asset_id}")
# Refresh UI
page.snack_bar = ft.SnackBar(ft.Text("Media gallery changed!"))
page.snack_bar.open = True
page.update()
media.on_change = on_change
await media.start_change_notify()
When packaging with Flet, the Flutter engine plugin is automatically linked into the mobile binary.
uv run flet build apk \
--split-per-abi \
--arch arm64-v8a \
--permissions camera microphone \
--yes
To build against a local development version of this repository:
uv run flet build apk \
--source-packages /path/to/flet_media_library/src/flutter/flet_media_library \
--permissions camera microphone \
--yes
# Simulator build
uv run flet build ios-simulator --permissions camera microphone --yes
# Production IPA
uv run flet build ipa \
--permissions camera microphone \
--info-plist NSPhotoLibraryUsageDescription="Browse and select media from device gallery" \
--info-plist NSPhotoLibraryAddUsageDescription="Save photos and captured media to library"
To run and contribute to flet-media-library:
git clone https://github.com/fazi-gondal/Flet-media-library.git
cd Flet-media-library
# Install Python package in editable mode
uv sync
# Test Python models & serialization
uv run pytest tests/
# Format Dart plugin code
cd src/flutter/flet_media_library
flutter pub get
flutter analyze
dart format --set-exit-if-changed --output=none .
To explore and test on a physical device or emulator, see the full-featured test harness in examples/media_library_demo/.
For a lightweight Android-only solution focused on saving, listing, deleting, and scanning videos through MediaStore, see flet-media-scanner.
flet-media-library provides a broader cross-platform media library API for Android and iOS, while flet-media-scanner is a smaller Android-focused extension built directly around the native MediaStore APIs.
photo_manager — The underlying cross-platform asset and album engine created by CaiJingLong / Alex Li and the FlutterCandies team.flet-camera & flet-video — Camera preview and media player controls used in the companion demo.flet-audio-recorder — Cross-platform audio recording integration for Flet.This project is licensed under the MIT License — see the LICENSE file for details.