An open API service indexing awesome lists of open source software.

https://github.com/iceywu/live-photo

A LivePhoto viewer for web applications๐Ÿ–ผ๏ธ
https://github.com/iceywu/live-photo

angular component image livephoto react vue

Last synced: 4 months ago
JSON representation

A LivePhoto viewer for web applications๐Ÿ–ผ๏ธ

Awesome Lists containing this project

README

          











live-photo


๐Ÿš€ live-photo โ€” A tiny, zero-dependency Live Photo web viewer that works with any modern front-end framework (Vue, React, Angular, Svelte) or plain JavaScript. Bring iOSโ€‘style Live Photos to the web with minimal code.


NPM version
NPM Downloads


Live demo: https://live-photo.netlify.app

**English** | [ไธญๆ–‡](./README.zh-CN.md)

## โœจ Features

- ๐ŸŽฏ **Zero Dependencies** - Lightweight implementation with no external dependencies
- ๐Ÿ“ฑ **Cross-Platform** - Seamless support for both mobile (touch) and desktop (mouse) interactions
- ๐Ÿ–ผ๏ธ **Smart Media Handling** - Automatic switching between photo and video with smooth transitions
- ๐ŸŽจ **Highly Customizable** - Flexible styling and configuration options for both image and video elements
- ๐Ÿ”„ **Advanced Loading** - Support for lazy loading and progressive video loading with visual feedback
- โšก **Performance Optimized** - Efficient resource management and clean-up mechanisms
- ๐ŸŽฎ **Rich API** - Comprehensive public methods and event callbacks for full control
- ๐ŸŽญ **Interactive Experience** - Long-press to play, click detection, auto-play modes, and haptic feedback
- ๏ฟฝ **State Management** - Built-in state tracking and subscription system
- ๐Ÿ›ก๏ธ **Type Safe** - Full TypeScript support with complete type definitions
- ๐ŸŽช **Framework Agnostic** - Works with vanilla JavaScript, Vue, React, Angular, and more

## ๐Ÿ“ฆ Installation

```bash
npm install live-photo
# or
pnpm add live-photo
# or
yarn add live-photo
# or
bun add live-photo
```

## ๐Ÿ› ๏ธ Utility Functions

### livePhotoExtract

Extract photo and video from Live Photo files (HEIC/MOV combined format).

```javascript
import { extractFromLivePhoto } from 'live-photo';

// Extract from file input
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
const result = await extractFromLivePhoto(file);

if (result) {
const { photoBlob, photoUrl, videoBlob, videoUrl } = result;

// Use extracted photo and video
const viewer = new LivePhotoViewer({
photoSrc: photoUrl,
videoSrc: videoUrl,
container: document.getElementById('container'),
});

// Clean up URLs when done
// URL.revokeObjectURL(photoUrl);
// URL.revokeObjectURL(videoUrl);
}
});
```

**Returns:**
```typescript
interface ExtractResult {
photoBlob: Blob; // JPEG image blob
photoUrl: string; // Object URL for the photo
videoBlob: Blob; // MP4 video blob
videoUrl: string; // Object URL for the video
}
```

**Note:** Remember to revoke object URLs when they're no longer needed to prevent memory leaks.

## ๐Ÿš€ Quick Start

### Browser (CDN)

```html

new LivePhotoViewer({
photoSrc: 'path/to/photo.jpg',
videoSrc: 'path/to/video.mp4',
container: document.getElementById('live-photo-container'),
});

```

### ES Module

```javascript
import { LivePhotoViewer } from 'live-photo';

const viewer = new LivePhotoViewer({
photoSrc: 'path/to/photo.jpg',
videoSrc: 'path/to/video.mp4',
container: document.getElementById('live-photo-container'),
});
```

## ๐Ÿ“– API Reference

### Configuration Options

| Parameter | Type | Required | Default | Description |
| ------------------ | -------------------------- | -------- | ------- | ------------------------------------------------------------------------ |
| photoSrc | string | โœ… | - | URL of the static image to display |
| videoSrc | string | โœ… | - | URL of the video to play on interaction |
| container | HTMLElement | โœ… | - | DOM element to mount the viewer |
| width | number \| string | โŒ | `300px` | Width of the viewer (supports px, %, vh, vw, etc.) |
| height | number \| string | โŒ | `300px` | Height of the viewer (supports px, %, vh, vw, etc.) |
| autoplay | boolean | โŒ | `true` | Enable automatic video playback on hover (desktop) or long-press (mobile)|
| lazyLoadVideo | boolean | โŒ | `false` | Delay video loading until viewer is in viewport |
| longPressDelay | number | โŒ | `300` | Time threshold (ms) to distinguish between click and long-press |
| borderRadius | number \| string | โŒ | - | Border radius for the container (supports px, %, rem, etc.) |
| theme | 'light' \| 'dark' \| 'auto'| โŒ | - | Color theme for UI elements |
| preload | 'auto' \| 'metadata' \| 'none' | โŒ | - | Video preload strategy |
| retryAttempts | number | โŒ | `3` | Number of retry attempts for failed video loads |
| enableVibration | boolean | โŒ | `true` | Enable haptic feedback on supported devices |
| staticBadgeIcon | boolean | โŒ | `false` | Keep badge icon static (no slash) regardless of autoplay state |
| imageCustomization | ElementCustomization | โŒ | - | Custom attributes and styles for the image element |
| videoCustomization | ElementCustomization | โŒ | - | Custom attributes and styles for the video element |

### ElementCustomization Interface

```typescript
interface ElementCustomization {
attributes?: Record; // HTML attributes (e.g., { alt: "...", loading: "lazy" })
styles?: Partial; // CSS styles (e.g., { objectFit: "cover" })
}
```

### Event Callbacks

All callbacks now return the original event object and related elements, giving you access to complete event information and element properties.

| Callback | Parameters | Description |
| -------------- | ------------------------ | ---------------------------------------------------- |
| onPhotoLoad | `(event, photo) => void` | Triggered when the photo finishes loading, returns event object and image element |
| onVideoLoad | `(duration, event, video) => void` | Triggered when video metadata is loaded, returns duration (seconds), event object and video element |
| onCanPlay | `(event, video) => void` | Triggered when the video is ready to play, returns event object and video element |
| onLoadStart | `() => void` | Triggered when video loading starts (lazy load mode) |
| onLoadProgress | `(loaded, total) => void` | Triggered during video download progress |
| onProgress | `(progress, event, video) => void` | Triggered with video buffering progress (0-100), returns progress, event object and video element |
| onEnded | `(event, video) => void` | Triggered when video playback completes, returns event object and video element |
| onClick | `(event) => void` | Triggered on short press/click, returns event object |
| onError | `(error, event?) => void` | Triggered when an error occurs, returns error object and optional event object |

### LivePhotoError Interface

```typescript
interface LivePhotoError {
type: 'VIDEO_LOAD_ERROR' | 'PHOTO_LOAD_ERROR' | 'PLAYBACK_ERROR' | 'VALIDATION_ERROR';
message: string;
originalError?: Error;
}
```

### Public Methods

All methods are available on the `LivePhotoViewer` instance:

| Method | Returns | Description |
| ---------------- | --------------- | ---------------------------------------------------- |
| `play()` | `Promise` | Start or resume video playback |
| `pause()` | `void` | Pause video playback |
| `stop()` | `void` | Stop video and reset to beginning |
| `toggle()` | `void` | Toggle between play and pause states |
| `getState()` | `LivePhotoState`| Get current viewer state (readonly) |
| `destroy()` | `void` | Clean up resources and remove viewer from DOM |

### LivePhotoState Interface

```typescript
interface LivePhotoState {
isPlaying: boolean; // Whether video is currently playing
autoplay: boolean; // Current autoplay setting
videoError: boolean; // Whether video loading failed
videoLoaded: boolean; // Whether video has been loaded
aspectRatio: number; // Calculated aspect ratio of the photo
isLongPressPlaying: boolean; // Whether playing due to long-press
}
```

## ๐ŸŽฏ How It Works

### Desktop Interaction
- **Hover on Badge**: Video plays automatically when hovering over the LIVE badge (if autoplay is enabled)
- **Hover Off**: Video stops and returns to photo
- **Click Badge**: Opens dropdown menu to toggle autoplay settings

### Mobile Interaction
- **Long Press**: Hold down on the photo to play the video
- **Release**: Video stops and returns to photo
- **Short Tap**: Triggers `onClick` callback without playing video
- **Haptic Feedback**: Vibration feedback on supported devices (if enabled)

### Loading Behavior
- **Standard Loading**: Video loads immediately with the component
- **Lazy Loading**: Video loads only when the viewer enters the viewport
- **Progress Indicator**: Visual feedback shows loading progress in the LIVE badge
- **Error Recovery**: Automatic retry mechanism for failed loads

## ๐ŸŽจ Customization

### Styling

```javascript
const viewer = new LivePhotoViewer({
photoSrc: "photo.jpg",
videoSrc: "video.mp4",
container: document.getElementById("container"),

// Container styling
width: "100%",
height: "auto",
borderRadius: "16px",
theme: "dark",

// Image customization
imageCustomization: {
styles: {
objectFit: "cover",
filter: "brightness(1.1)",
},
attributes: {
alt: "My Live Photo",
loading: "lazy",
draggable: "false",
},
},

// Video customization
videoCustomization: {
styles: {
objectFit: "contain",
filter: "contrast(1.1)",
},
attributes: {
preload: "metadata",
},
},
});
```

### Custom CSS

You can override the default styles using CSS:

```css
/* Container */
.live-photo-container {
border: 2px solid #4F46E5;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

/* Badge */
.live-photo-badge {
background: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
}

/* Playing state */
.live-photo-container.playing {
transform: scale(1.02);
transition: transform 0.3s ease;
}

/* Dropdown menu */
.dropdown-menu {
background: rgba(255, 255, 255, 0.95);
}
```

## ๐Ÿ”ง Browser Support

- โœ… Chrome (latest)
- โœ… Firefox (latest)
- โœ… Safari (latest)
- โœ… Edge (latest)
- โœ… Mobile browsers (iOS Safari, Chrome Mobile)

### Requirements
- Modern browser with ES6+ support
- Support for `IntersectionObserver` (for lazy loading)
- Support for `Promise` and `async/await`

## ๐Ÿ“‹ Best Practices

1. **Optimize Media Files**
- Use compressed images (JPEG, WebP)
- Use short video clips (2-3 seconds recommended)
- Consider using adaptive bitrate videos for better performance

2. **Use Lazy Loading**
- Enable `lazyLoadVideo: true` for content below the fold
- Improves initial page load performance

3. **Handle Errors Gracefully**
- Always implement `onError` callback
- Provide fallback UI for failed loads

4. **Clean Up Resources**
- Call `destroy()` method when removing the viewer
- Especially important in SPAs (Single Page Applications)

5. **Responsive Design**
- Use relative units (`%`, `vh`, `vw`) for responsive sizing
- Set appropriate `objectFit` values for your aspect ratios

## ๐Ÿ› Troubleshooting

### Video not playing on mobile
- Ensure video has `muted` attribute (automatically set by the component)
- Check that video format is supported (MP4 H.264 recommended)
- Verify that `playsInline` is set (automatically set by the component)

### Video not loading
- Check video URL is accessible and CORS-enabled
- Verify video file is not corrupted
- Check browser console for specific errors
- Try increasing `retryAttempts` option

### Performance issues
- Enable `lazyLoadVideo` for multiple viewers on one page
- Optimize video file size and format
- Consider using shorter video clips

### Autoplay not working
- Verify `autoplay: true` is set in options
- Check that video is muted (required for autoplay in browsers)
- Desktop: Ensure you're hovering over the badge
- Mobile: Use long-press instead (autoplay works differently)

## ๐Ÿ”ง Development

```bash
# Install dependencies
pnpm install

# Development mode with watch
pnpm dev

# Build for production
pnpm build

# Run playground
cd playground
pnpm dev
```

## ๐Ÿ“„ License

MIT License - see [LICENSE](LICENSE) file for details

## ๐Ÿค Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

## ๐Ÿ’– Support

If you find this project helpful, please consider:
- โญ Starring the repository
- ๐Ÿ› Reporting bugs
- ๐Ÿ’ก Suggesting new features
- ๐Ÿ“– Improving documentation

## ๐Ÿ“ฌ Contact

- Author: Icey Wu
- Email: 3128006406@qq.com
- GitHub: [@IceyWu](https://github.com/iceywu)

## ๐Ÿ™ Acknowledgments

Inspired by Apple's Live Photos feature on iOS devices.

---

Made with โค๏ธ by [Icey Wu](https://github.com/iceywu)

## ๏ฟฝ Usage Examples

### Vanilla JavaScript

[View complete HTML demo](./demo/html-demo.html)

```html



Live Photo Demo


document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById("live-photo-container");

const viewer = new LivePhotoViewer({
photoSrc: "https://example.com/photo.jpg",
videoSrc: "https://example.com/video.mp4",
container: container,
width: 400,
height: 600,
borderRadius: "12px",
autoplay: true,
lazyLoadVideo: true,
enableVibration: true,
imageCustomization: {
styles: {
objectFit: "cover",
},
attributes: {
alt: "Beautiful Live Photo",
loading: "lazy",
},
},
videoCustomization: {
styles: {
objectFit: "cover",
},
},
// Event callbacks
onPhotoLoad: (event, photo) => {
console.log("Photo loaded", photo.naturalWidth, "x", photo.naturalHeight);
},
onVideoLoad: (duration, event, video) => {
console.log(`Video loaded, duration: ${duration}s`);
},
onProgress: (progress, event, video) => {
console.log(`Loading: ${progress}%`);
},
onError: (error, event) => console.error("Error:", error),
onClick: (event) => console.log("Clicked!"),
});

// Control playback programmatically
// viewer.play();
// viewer.pause();
// viewer.stop();
// viewer.toggle();

// Get current state
// const state = viewer.getState();
// console.log(state.isPlaying, state.autoplay);
});

```

### Vue 3 (Composition API)

[View complete Vue 3 demo](./demo/vue3-demo.html)

```vue

import { ref, onMounted, onUnmounted } from "vue";
import { LivePhotoViewer } from "live-photo";
import type { LivePhotoAPI } from "live-photo";

const containerRef = ref<HTMLElement | null>(null);
const viewerInstance = ref<LivePhotoAPI | null>(null);

onMounted(() => {
if (containerRef.value) {
viewerInstance.value = new LivePhotoViewer({
photoSrc: "https://example.com/photo.jpg",
videoSrc: "https://example.com/video.mp4",
container: containerRef.value,
width: 400,
height: 600,
borderRadius: "12px",
autoplay: true,
lazyLoadVideo: true,
enableVibration: true,
imageCustomization: {
styles: {
objectFit: "cover",
},
attributes: {
alt: "Beautiful Live Photo",
loading: "lazy",
},
},
videoCustomization: {
styles: {
objectFit: "cover",
},
},
onPhotoLoad: (event, photo) => {
console.log("Photo loaded", photo.naturalWidth, "x", photo.naturalHeight);
},
onVideoLoad: (duration, event, video) => {
console.log(`Video loaded, duration: ${duration}s`);
},
onProgress: (progress, event, video) => {
console.log(`Loading: ${progress}%`);
},
onError: (error, event) => console.error("Error:", error),
onClick: (event) => console.log("Clicked!"),
});
}
});

// Clean up on component unmount
onUnmounted(() => {
if (viewerInstance.value) {
viewerInstance.value.destroy();
}
});

// Example: Control methods
const play = () => viewerInstance.value?.play();
const pause = () => viewerInstance.value?.pause();
const toggle = () => viewerInstance.value?.toggle();

```

### React (TypeScript)

[View complete React demo](./demo/react-demo.html)

```tsx
import React, { useEffect, useRef } from "react";
import { LivePhotoViewer } from "live-photo";
import type { LivePhotoAPI } from "live-photo";

const LivePhotoComponent: React.FC = () => {
const containerRef = useRef(null);
const viewerRef = useRef(null);

useEffect(() => {
if (containerRef.current) {
viewerRef.current = new LivePhotoViewer({
photoSrc: "https://example.com/photo.jpg",
videoSrc: "https://example.com/video.mp4",
container: containerRef.current,
width: 400,
height: 600,
borderRadius: "12px",
autoplay: true,
lazyLoadVideo: true,
enableVibration: true,
imageCustomization: {
styles: {
objectFit: "cover",
},
attributes: {
alt: "Beautiful Live Photo",
loading: "lazy",
},
},
videoCustomization: {
styles: {
objectFit: "cover",
},
},
onPhotoLoad: (event, photo) => {
console.log("Photo loaded", photo.naturalWidth, "x", photo.naturalHeight);
},
onVideoLoad: (duration, event, video) => {
console.log(`Video loaded, duration: ${duration}s`);
},
onProgress: (progress, event, video) => {
console.log(`Loading: ${progress}%`);
},
onError: (error, event) => console.error("Error:", error),
onClick: (event) => console.log("Clicked!"),
});
}

// Cleanup on unmount
return () => {
if (viewerRef.current) {
viewerRef.current.destroy();
}
};
}, []);

// Example: Control methods
const handlePlay = () => viewerRef.current?.play();
const handlePause = () => viewerRef.current?.pause();
const handleToggle = () => viewerRef.current?.toggle();

return (




Play
Pause
Toggle


);
};

export default LivePhotoComponent;
```

### Advanced Usage

#### Accessing Callback Parameters

All callbacks now provide the original event object and related elements, giving you access to complete DOM information:

```javascript
const viewer = new LivePhotoViewer({
photoSrc: "photo.jpg",
videoSrc: "video.mp4",
container: document.getElementById("container"),

onVideoLoad: (duration, event, video) => {
// duration: Video duration in seconds (HTML5 Video API standard unit)
console.log(`Video duration: ${duration}s`);

// event: Original DOM event object
console.log("Event type:", event.type); // "loadedmetadata"
console.log("Is trusted:", event.isTrusted);

// video: HTMLVideoElement element
console.log("Video dimensions:", video.videoWidth, "x", video.videoHeight);
console.log("Current time:", video.currentTime);
console.log("Ready state:", video.readyState);
},

onProgress: (progress, event, video) => {
// progress: Loading progress percentage (0-100)
console.log(`Progress: ${progress}%`);

// Access more information through the video element
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(0);
console.log(`Buffered: ${bufferedEnd}s`);
}
},

onPhotoLoad: (event, photo) => {
// photo: HTMLImageElement element
console.log("Image natural size:", photo.naturalWidth, "x", photo.naturalHeight);
console.log("Image display size:", photo.width, "x", photo.height);
console.log("Image source:", photo.src);
},

onError: (error, event) => {
console.log("Error type:", error.type);
console.log("Error message:", error.message);
// event is optional, some errors may not have an associated event
if (event) {
console.log("Error event:", event);
}
},
});
```

#### Lazy Loading with Intersection Observer

```javascript
const viewer = new LivePhotoViewer({
photoSrc: "photo.jpg",
videoSrc: "video.mp4",
container: document.getElementById("container"),
lazyLoadVideo: true, // Video loads only when viewer is in viewport
onLoadStart: () => {
console.log("Video loading started");
},
onProgress: (progress, event, video) => {
console.log(`Video buffering: ${progress}%`);
console.log(`Buffered: ${video.buffered.length > 0 ? video.buffered.end(0) : 0}s`);
},
});
```

#### Custom Error Handling

```javascript
const viewer = new LivePhotoViewer({
photoSrc: "photo.jpg",
videoSrc: "video.mp4",
container: document.getElementById("container"),
retryAttempts: 5, // Retry 5 times on failure
onError: (error, event) => {
console.log("Original event:", event);
switch (error.type) {
case 'VIDEO_LOAD_ERROR':
console.error("Failed to load video:", error.message);
// Show fallback UI
break;
case 'PHOTO_LOAD_ERROR':
console.error("Failed to load photo:", error.message);
break;
case 'PLAYBACK_ERROR':
console.error("Playback failed:", error.message);
break;
}
},
});
```

#### State Subscription

```javascript
const viewer = new LivePhotoViewer({
photoSrc: "photo.jpg",
videoSrc: "video.mp4",
container: document.getElementById("container"),
});

// Get current state
const state = viewer.getState();
console.log(state.isPlaying); // false
console.log(state.autoplay); // true

// Note: For reactive state updates, you can poll getState()
// or use the event callbacks
```

#### Responsive Sizing

```javascript
const viewer = new LivePhotoViewer({
photoSrc: "photo.jpg",
videoSrc: "video.mp4",
container: document.getElementById("container"),
width: "100%", // Responsive width
height: "50vh", // 50% of viewport height
borderRadius: "1rem",
imageCustomization: {
styles: {
objectFit: "cover", // Maintain aspect ratio
},
},
});
```