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๐ผ๏ธ
- Host: GitHub
- URL: https://github.com/iceywu/live-photo
- Owner: IceyWu
- Created: 2024-12-25T09:24:42.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2026-02-05T03:24:42.000Z (4 months ago)
- Last Synced: 2026-02-05T13:59:07.025Z (4 months ago)
- Topics: angular, component, image, livephoto, react, vue
- Language: TypeScript
- Homepage: https://live-photo.netlify.app
- Size: 286 KB
- Stars: 5
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
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.
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
},
},
});
```