Integrating Moving Photo with Media Library Kit in HarmonyOS Apps
This article demonstrates how to integrate HarmonyOS Media Library Kit's Moving Photo capability into a real app, covering selection, import, sandbox storage, data modeling, and playback with MovingPhotoView using the Time Travel Journal app as a case study.
Why Not Just Save a Single Image
A Moving Photo is not a single animated JPG but a static image plus a short video. Media Library Kit abstracts this as photoAccessHelper.MovingPhoto and plays it via MovingPhotoView. In Time Travel Journal, the author needs the "moment" to remain complete offline, after app exit, or during local backup. Therefore, on import the Moving Photo is split into two parts: a static image for covers, grids, and list previews, and a video resource used only when the user enters full-screen preview and taps play. This avoids keeping a player alive in ordinary UI and sidesteps the AVPlayer concurrency limit (no more than 3 simultaneous instances) mentioned in the official docs.
Selection and Import Flow
In MainPage.ets the system picker is launched with PhotoViewPicker using PhotoViewMIMETypes.IMAGE_TYPE so both regular images and Moving Photos enter the same import pipeline. The logic tries to handle each URI as a Moving Photo first via requestMovingPhoto; if that fails it falls back to regular image import. The key insight: the picker returns media URIs, so the business layer cannot rely on file extensions alone.
private async preparePhotoImportMediaRecords(
hostContext: Context,
notebookId: string,
assetUris: Array<string>,
readableUris: Array<string>,
fallbackExtension: string,
logTag: string
): Promise<PhotoImportPrepareResult> {
let result: PhotoImportPrepareResult = new PhotoImportPrepareResult();
let movingEntries: Array<PhotoImportPreparedEntry> = [];
let normalAssetUris: Array<string> = [];
let normalOrders: Array<number> = [];
let helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(hostContext);
let totalCount: number = assetUris.length > 0 ? assetUris.length : readableUris.length;
for (let i: number = 0; i < totalCount; i++) {
let assetUri: string = i < assetUris.length ? assetUris[i] : '';
if (assetUri.length === 0) {
if (i < readableUris.length && readableUris[i].length > 0) {
normalAssetUris.push(readableUris[i]);
normalOrders.push(i);
}
continue;
}
let preferredName: string = guessFileName(assetUri, MediaKind.PHOTO, fallbackExtension);
try {
let media: LocalMediaRecord = await createMovingPhotoMediaRecord(
hostContext,
notebookId,
assetUri,
preferredName
);
let entry: PhotoImportPreparedEntry = new PhotoImportPreparedEntry();
entry.assetUri = assetUri;
entry.sourceUri = assetUri;
entry.media = media;
entry.order = i;
movingEntries.push(entry);
console.info(
`[${logTag}] stage=moving-photo-copied index=${i.toString()} ` +
`assetUri=${assetUri} targetUri=${media.localUri} videoUri=${media.movingPhotoVideoUri}`
);
} catch (_movingPhotoError) {
normalAssetUris.push(assetUri);
normalOrders.push(i);
}
}
if (movingEntries.length > 0) {
let movingAssetUris: Array<string> = movingEntries.map((entry: PhotoImportPreparedEntry): string => entry.assetUri);
let movingReadableUris: Array<string> = await this.requestReadableUrisForPhotoImport(helper, movingAssetUris);
for (let i: number = 0; i < movingEntries.length; i++) {
let entry: PhotoImportPreparedEntry = movingEntries[i];
if (i < movingReadableUris.length && movingReadableUris[i].length > 0) {
entry.sourceUri = movingReadableUris[i];
entry.media.sourceUri = entry.sourceUri;
}
result.entries.push(entry);
}
}
if (normalAssetUris.length > 0) {
let normalReadableUris: Array<string> = await this.requestReadableUrisForPhotoImport(helper, normalAssetUris);
for (let i: number = 0; i < normalAssetUris.length; i++) {
let sourceUri: string = i < normalReadableUris.length && normalReadableUris[i].length > 0
? normalReadableUris[i] : normalAssetUris[i];
try {
let preferredName: string = guessFileName(sourceUri, MediaKind.PHOTO, fallbackExtension);
let target: SandboxFileTarget = await copyUriToSandbox(
hostContext,
notebookId,
sourceUri,
MediaKind.PHOTO,
preferredName,
fallbackExtension
);
let entry: PhotoImportPreparedEntry = new PhotoImportPreparedEntry();
entry.assetUri = normalAssetUris[i];
entry.sourceUri = sourceUri;
entry.media = createMediaRecord(MediaKind.PHOTO, sourceUri, target);
entry.order = normalOrders[i];
result.entries.push(entry);
} catch (_error) {
result.failedCount = result.failedCount + 1;
}
}
}
result.entries = result.entries.sort((left: PhotoImportPreparedEntry, right: PhotoImportPreparedEntry): number => {
return left.order - right.order;
});
return result;
}Fetching MovingPhoto from Media Library
The real interaction happens in TimeImprintService.ets. First, PhotoAccessHelper.getAssets queries a PhotoAsset by URI, then MediaAssetManager.requestMovingPhoto retrieves the MovingPhoto. The author uses HIGH_QUALITY_MODE because the app is for memory preservation, not quick preview, so waiting longer for stable resource quality is acceptable.
async function fetchPhotoAssetByUri(context: Context, sourceUri: string): Promise<photoAccessHelper.PhotoAsset> {
let helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
let predicates: dataSharePredicates.DataSharePredicates = new dataSharePredicates.DataSharePredicates();
predicates.equalTo(photoAccessHelper.PhotoKeys.URI, sourceUri);
let options: photoAccessHelper.FetchOptions = {
fetchColumns: [
photoAccessHelper.PhotoKeys.URI,
photoAccessHelper.PhotoKeys.PHOTO_TYPE,
photoAccessHelper.PhotoKeys.DISPLAY_NAME,
photoAccessHelper.PhotoKeys.DATE_TAKEN_MS,
photoAccessHelper.PhotoKeys.DATE_TAKEN
],
predicates: predicates
};
let fetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> = await helper.getAssets(options);
try {
if (fetchResult.getCount() <= 0) {
throw new Error(`photo asset not found: ${sourceUri}`);
}
return await fetchResult.getFirstObject();
} finally {
fetchResult.close();
}
}
async function requestMovingPhotoFromMediaLibrary(
context: Context,
sourceUri: string
): Promise<photoAccessHelper.MovingPhoto> {
let asset: photoAccessHelper.PhotoAsset = await fetchPhotoAssetByUri(context, sourceUri);
let options: photoAccessHelper.RequestOptions = {
deliveryMode: photoAccessHelper.DeliveryMode.HIGH_QUALITY_MODE
};
return new Promise<photoAccessHelper.MovingPhoto>((resolve, reject) => {
photoAccessHelper.MediaAssetManager.requestMovingPhoto(context, asset, options, {
onDataPrepared(movingPhoto: photoAccessHelper.MovingPhoto | undefined): void {
if (movingPhoto === undefined) {
reject(new Error('moving photo not found'));
return;
}
resolve(movingPhoto);
}
}).catch(() => {
reject(new Error('request moving photo failed'));
});
});
}After obtaining the MovingPhoto object, do not store it directly in the business model for the long term; it is a runtime resource. Instead, persist the image file, video file, and metadata.
export async function createMovingPhotoMediaRecord(
context: Context,
notebookId: string,
sourceUri: string,
preferredName: string
): Promise<LocalMediaRecord> {
let movingPhoto: photoAccessHelper.MovingPhoto = await requestMovingPhotoFromMediaLibrary(context, sourceUri);
let imageTarget: SandboxFileTarget = await prepareSandboxFile(
context,
notebookId,
MediaKind.PHOTO,
preferredName,
'jpg'
);
let videoTarget: SandboxFileTarget = await prepareSandboxFile(
context,
notebookId,
MediaKind.VIDEO,
buildMovingPhotoVideoName(preferredName),
'mp4'
);
await movingPhoto.requestContent(imageTarget.filePath, videoTarget.filePath);
let media: LocalMediaRecord = createMediaRecord(MediaKind.PHOTO, sourceUri, imageTarget);
media.isMovingPhoto = true;
media.movingPhotoVideoPath = videoTarget.filePath;
media.movingPhotoVideoUri = videoTarget.fileUri;
media.movingPhotoSourceVideoUri = movingPhoto.getUri();
return media;
}
function buildMovingPhotoVideoName(imagePreferredName: string): string {
let clean: string = imagePreferredName.trim();
let dotIndex: number = clean.lastIndexOf('.');
if (dotIndex > 0) {
return clean.substring(0, dotIndex) + '.mp4';
}
return clean.length > 0 ? clean + '.mp4' : 'moving_photo.mp4';
}A common pitfall: requestContent(imageTarget.filePath, videoTarget.filePath) expects sandbox file paths, not media library URIs. After export, fileUri.getUriFromPath generates file:// URIs for ArkUI components.
Data Model and Persistence
LocalMediaRecordtreats Moving Photo as an enhanced photo record, not a separate video record. It remains a photo with extra playable content.
export class LocalMediaRecord {
id: string = '';
kind: MediaKind = MediaKind.PHOTO;
fileName: string = '';
localPath: string = '';
localUri: string = '';
sourceUri: string = '';
originalLocalPath: string = '';
originalLocalUri: string = '';
thumbnailPath: string = '';
thumbnailUri: string = '';
originalCloudUri: string = '';
previewCloudUri: string = '';
isPreviewCompressed: boolean = false;
isMovingPhoto: boolean = false;
movingPhotoVideoPath: string = '';
movingPhotoVideoUri: string = '';
movingPhotoSourceVideoUri: string = '';
createdAt: string = '';
}SQLite columns added:
is_moving_photo INTEGER NOT NULL DEFAULT 0,
moving_photo_video_path TEXT NOT NULL DEFAULT '',
moving_photo_video_uri TEXT NOT NULL DEFAULT '',
moving_photo_source_video_uri TEXT NOT NULL DEFAULT ''Reading back into the model:
media.isMovingPhoto = resultSet.getLong(resultSet.getColumnIndex('is_moving_photo')) === 1;
media.movingPhotoVideoPath = resultSet.getString(resultSet.getColumnIndex('moving_photo_video_path'));
media.movingPhotoVideoUri = resultSet.getString(resultSet.getColumnIndex('moving_photo_video_uri'));
media.movingPhotoSourceVideoUri = resultSet.getString(resultSet.getColumnIndex('moving_photo_source_video_uri'));Writing to database:
mediaValues.push({
id: media.id,
moment_id: moment.id,
sort_order: mediaIndex,
kind: media.kind,
file_name: media.fileName,
local_path: media.localPath,
local_uri: media.localUri,
source_uri: media.sourceUri,
original_local_path: media.originalLocalPath,
original_local_uri: media.originalLocalUri,
thumbnail_path: media.thumbnailPath,
thumbnail_uri: media.thumbnailUri,
original_cloud_uri: media.originalCloudUri,
preview_cloud_uri: media.previewCloudUri,
is_preview_compressed: media.isPreviewCompressed ? 1 : 0,
is_moving_photo: media.isMovingPhoto ? 1 : 0,
moving_photo_video_path: media.movingPhotoVideoPath,
moving_photo_video_uri: media.movingPhotoVideoUri,
moving_photo_source_video_uri: media.movingPhotoSourceVideoUri,
created_at: media.createdAt
});Marking Moving Photos in Detail View
In the moment detail page, Moving Photos are not auto-played in lists. Lists need stability, low power, and fast scrolling, so only the static image is shown with a livephoto icon overlay.
private isMovingPhotoItem(item: LocalMediaRecord): boolean {
return item.kind === MediaKind.PHOTO && item.isMovingPhoto && item.movingPhotoVideoUri.length > 0;
}Thumbnail badge implementation:
Stack({ alignContent: Alignment.Center }) {
Image(item.localUri)
.width(80)
.height(80)
.objectFit(ImageFit.Cover)
.orientation(ImageRotateOrientation.AUTO)
.borderRadius(16)
if (this.isMovingPhotoItem(item)) {
Row() {
SymbolGlyph($r('sys.symbol.livephoto'))
.fontSize(17)
.fontColor([Color.White])
}
.width(24)
.height(24)
.justifyContent(FlexAlign.Center)
.backgroundColor('#66000000')
.borderRadius(12)
.position({ left: 6, bottom: 6 })
}
}Restoring MovingPhoto in Full-Screen Preview
When entering ImagePreviewDialog, the outer component passes both image URIs and video URI arrays. Regular images have empty video URIs; Moving Photos have movingPhotoVideoUri.
ImagePreviewDialog({
imageUri: $previewImageUri,
imageIndex: $previewImageIndex,
imageUris: this.previewImageUris,
movingPhotoVideoUris: this.previewMovingPhotoVideoUris,
expressionAssistEnabled: this.store.microExpressionPhotoAssistEnabled,
onClose: () => {
this.closeImagePreview();
},
onImageChange: (index: number, uri: string) => {
this.handleImagePreviewIndexChange(index, uri);
}
})Assembly of movingPhotoVideoUris:
private resolvePreviewMovingPhotoVideoUris(previewUris: Array<string>): Array<string> {
const result: Array<string> = [];
const moment: MomentRecord | undefined = this.showMomentDetail ? this.getCurrentMomentDetail() : undefined;
const photoItems: Array<LocalMediaRecord> = moment === undefined ? [] : this.getMomentPhotoMediaItems(moment);
for (let i: number = 0; i < previewUris.length; i++) {
let videoUri: string = '';
if (i < photoItems.length && photoItems[i].isMovingPhoto && photoItems[i].movingPhotoVideoUri.length > 0) {
videoUri = photoItems[i].movingPhotoVideoUri;
}
result.push(videoUri);
}
return result;
}Inside ImagePreviewDialog, MediaAssetManager.loadMovingPhoto reconstructs a runtime MovingPhoto from the sandbox image and video.
private async prepareMovingPhoto(): Promise<void> {
this.stopMovingPhotoPlayback();
this.clearMovingPhotoViewReadyTimer();
this.clearMovingPhotoPlaybackRetryTimer();
this.movingPhoto = undefined;
this.movingPhotoLoadFailed = false;
this.movingPhotoPlaying = false;
this.movingPhotoViewReady = false;
const currentImageUri: string = this.imageUri;
const currentVideoUri: string = this.getCurrentMovingPhotoVideoUri();
this.activeMovingPhotoVideoUri = currentVideoUri;
if (currentImageUri.length === 0 || currentVideoUri.length === 0) {
this.movingPhotoLoading = false;
return;
}
const hostContext: Context | undefined = this.getUIContext().getHostContext();
if (hostContext === undefined) {
this.movingPhotoLoading = false;
this.movingPhotoLoadFailed = true;
return;
}
this.movingPhotoLoading = true;
try {
const movingPhoto: photoAccessHelper.MovingPhoto =
await photoAccessHelper.MediaAssetManager.loadMovingPhoto(
hostContext,
currentImageUri,
currentVideoUri
);
if (this.imageUri !== currentImageUri || this.getCurrentMovingPhotoVideoUri() !== currentVideoUri) {
return;
}
this.movingPhoto = movingPhoto;
this.movingPhotoLoadFailed = false;
} catch (_error) {
if (this.imageUri === currentImageUri && this.getCurrentMovingPhotoVideoUri() === currentVideoUri) {
this.movingPhotoLoadFailed = true;
}
}
if (this.imageUri === currentImageUri && this.getCurrentMovingPhotoVideoUri() === currentVideoUri) {
this.movingPhotoLoading = false;
}
}Playback uses MovingPhotoView. The official docs note that MovingPhotoView uses AVPlayer underneath, and concurrent AVPlayers should not exceed 3. Therefore, the component is only rendered when the current page is a Moving Photo, and playback is actively stopped when the page disappears.
private readonly movingPhotoController: MovingPhotoViewController = new MovingPhotoViewController();
private stopMovingPhotoPlayback(): void {
this.clearMovingPhotoPlaybackRetryTimer();
try {
this.movingPhotoController.stopPlayback();
} catch (_error) {
}
this.movingPhotoPlaying = false;
}
private startMovingPhotoPlayback(): void {
if (this.movingPhoto === undefined || this.movingPhotoLoadFailed || !this.movingPhotoViewReady) {
return;
}
this.clearMovingPhotoPlaybackRetryTimer();
this.tryStartMovingPhotoPlayback();
this.movingPhotoPlaybackRetryTimer = setTimeout(() => {
this.movingPhotoPlaybackRetryTimer = -1;
if (this.movingPhoto !== undefined && !this.movingPhotoPlaying && !this.movingPhotoLoadFailed &&
this.movingPhotoViewReady) {
this.tryStartMovingPhotoPlayback();
}
}, 160);
}
private tryStartMovingPhotoPlayback(): void {
try {
this.movingPhotoController.startPlayback();
} catch (_error) {
this.movingPhotoPlaying = false;
}
}UI rendering:
if (this.movingPhoto !== undefined && this.activeMovingPhotoVideoUri === this.getMovingPhotoVideoUri(index)) {
MovingPhotoView({
movingPhoto: this.movingPhoto,
controller: this.movingPhotoController
})
.width('100%')
.height('100%')
.muted(false)
.objectFit(ImageFit.Contain)
.scale({ x: this.previewScale, y: this.previewScale })
.translate({ x: this.previewOffsetX, y: this.previewOffsetY, z: 0 })
.onAppear(() => {
this.scheduleMovingPhotoViewReady(this.getMovingPhotoVideoUri(index));
})
.onDisAppear(() => {
this.movingPhotoViewReady = false;
this.clearMovingPhotoViewReadyTimer();
this.clearMovingPhotoPlaybackRetryTimer();
})
.onStart(() => {
this.clearMovingPhotoPlaybackRetryTimer();
this.movingPhotoPlaying = true;
})
.onFinish(() => {
this.clearMovingPhotoPlaybackRetryTimer();
this.movingPhotoPlaying = false;
})
.onStop(() => {
this.clearMovingPhotoPlaybackRetryTimer();
this.movingPhotoPlaying = false;
})
.onError(() => {
this.clearMovingPhotoPlaybackRetryTimer();
this.movingPhotoLoadFailed = true;
this.movingPhotoPlaying = false;
})
}A play button is overlaid in the center so users know the photo is playable:
if (this.movingPhotoLoading) {
LoadingProgress()
.width(32)
.height(32)
.color(Color.White)
} else if (this.movingPhoto !== undefined && this.movingPhotoViewReady && !this.movingPhotoLoadFailed &&
!this.movingPhotoPlaying) {
Button({ type: ButtonType.Circle, stateEffect: true }) {
SymbolGlyph($r('sys.symbol.play_fill'))
.fontSize(22)
.fontColor([Color.White])
}
.width(52)
.height(52)
.backgroundColor('#66000000')
.onClick(() => {
this.startMovingPhotoPlayback();
})
}Permissions and Compliance Boundaries
The project does not request ohos.permission.READ_IMAGEVIDEO or ohos.permission.WRITE_IMAGEVIDEO. Reading user-selected photos relies on PhotoViewPicker and requestPhotoUrisReadPermission returning authorized URIs. Only when reading EXIF location from photos does the app request ohos.permission.MEDIA_LOCATION. This approach is friendlier to third-party apps: Moving Photo import does not require full album access; the app only processes the few photos the user explicitly chose.
If the business needs to save a set of sandbox images and videos back to the system gallery as a Moving Photo, the secure SaveButton component can be used. On successful click, create a MediaAssetChangeRequest, specify PhotoSubtype.MOVING_PHOTO, and add image and video resources separately.
import { common } from '@kit.AbilityKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
@Entry
@Component
struct SaveMovingPhotoDemo {
@State message: string = '保存动态照片';
private saveButtonOptions: SaveButtonOptions = {
icon: SaveIconStyle.FULL_FILLED,
text: SaveDescription.SAVE_IMAGE,
buttonType: ButtonType.Capsule
};
build() {
Column({ space: 16 }) {
Text(this.message)
.fontSize(16)
SaveButton(this.saveButtonOptions)
.onClick(async (_event, result: SaveButtonOnClickResult) => {
if (result !== SaveButtonOnClickResult.SUCCESS) {
this.message = '用户未授权保存';
return;
}
try {
const context: common.UIAbilityContext =
this.getUIContext().getHostContext() as common.UIAbilityContext;
const helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
const imageFileUri: string = 'file://' + context.filesDir + '/moving_photo.jpg';
const videoFileUri: string = 'file://' + context.filesDir + '/moving_photo.mp4';
const request: photoAccessHelper.MediaAssetChangeRequest =
photoAccessHelper.MediaAssetChangeRequest.createAssetRequest(
context,
photoAccessHelper.PhotoType.IMAGE,
'jpg',
{
title: 'time_imprint_moving_photo',
subtype: photoAccessHelper.PhotoSubtype.MOVING_PHOTO
}
);
request.addResource(photoAccessHelper.ResourceType.IMAGE_RESOURCE, imageFileUri);
request.addResource(photoAccessHelper.ResourceType.VIDEO_RESOURCE, videoFileUri);
await helper.applyChanges(request);
this.message = '动态照片已保存到图库';
} catch (error) {
this.message = `保存失败:${JSON.stringify(error)}`;
}
})
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
}The official requirement limits Moving Photo video duration to 10 seconds; this must be validated before exporting as a Moving Photo.
Complete Minimal Usable Code
The article provides two self-contained files: MovingPhotoImportService.ets for import and MovingPhotoPreview.ets for preview. In the real project, the code is split across TimeImprintService.ets, MainPage.ets, and ImagePreviewDialog.ets.
Summary
The core approach for integrating Moving Photo in Time Travel Journal: use Media Library Kit to identify and read Moving Photos, save static image and video to sandbox, persist as an "enhanced photo" in the business model, and restore to MovingPhoto for playback in full-screen preview. This method suits diary-style apps better than fetching from the system gallery every time. The app preserves not just the photo but the few seconds of sound and motion around the shutter press.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
