Why Video Won't Play on iOS — playsinline, Autoplay & the play() Promise
The real reasons a <video> goes fullscreen, refuses to autoplay, or stays silent on iPhone — and the exact playsinline + muted + autoplay recipe (plus a tap-to-play fallback) that fixes it. With a live demo.
You drop a <video autoplay> into a page, it works on your laptop, you ship it — and on an iPhone it either hijacks the whole screen, refuses to autoplay, or plays without sound {Bạn thả một <video autoplay> vào trang, nó chạy ngon trên laptop, bạn ship — rồi trên iPhone nó hoặc chiếm trọn màn hình, hoặc không chịu autoplay, hoặc phát mà không có tiếng}. None of this is a bug {Không cái nào là bug}: it’s iOS deliberately protecting the user from surprise fullscreen video, autoplaying noise, and wasted battery {đó là iOS cố tình bảo vệ người dùng khỏi video bung fullscreen bất ngờ, tiếng ồn tự phát, và hao pin}.
This post is the field guide to those rules and the exact attributes that satisfy them {Bài này là cẩm nang về các luật đó và đúng những attribute để thỏa mãn chúng}.
iOS has three separate rules {iOS có ba luật riêng biệt}
Most “iOS video” confusion comes from treating these as one problem. They’re three {Phần lớn rối rắm về “video iOS” đến từ việc gộp chúng làm một. Thực ra là ba}:
┌─────────────────┬──────────────────────────────┬───────────────────────────┐
│ Rule │ Default on iPhone │ How to satisfy it │
├─────────────────┼──────────────────────────────┼───────────────────────────┤
│ Inline play │ forces NATIVE fullscreen │ playsinline │
│ Autoplay │ blocked │ muted + playsinline │
│ Sound on │ needs a user gesture │ user tap → unmute/play │
└─────────────────┴──────────────────────────────┴───────────────────────────┘
Solve them in order. Inline first (so video sits in your layout), then autoplay (so it starts itself), then sound (only ever after a tap) {Giải theo thứ tự. Inline trước (để video nằm trong layout), rồi autoplay (để tự chạy), rồi tiếng (chỉ sau một cú chạm)}.
Rule 1 — playsinline: stop the fullscreen takeover {playsinline: chặn việc bung toàn màn hình}
By default, an iPhone (not iPad) plays a <video> in its native fullscreen player the moment it starts {Mặc định, iPhone (không phải iPad) phát <video> trong trình phát toàn màn hình gốc ngay khi bắt đầu}. That ruins background videos, product loops, and any video meant to sit inside your design {Điều đó phá hỏng video nền, video sản phẩm dạng loop, và bất kỳ video nào cần nằm bên trong thiết kế của bạn}.
The fix is one attribute {Cách sửa là một attribute}:
<video src="/clip.mp4" playsinline webkit-playsinline></video>
playsinline— the standard HTML attribute {attribute HTML tiêu chuẩn}.webkit-playsinline— the legacy prefixed version for older iOS (iOS 9 and below); harmless to keep for safety {phiên bản tiền tố cũ cho iOS đời cũ (iOS 9 trở xuống); giữ lại cho chắc cũng vô hại}.
In JavaScript the property is camel-cased {Trong JavaScript, property viết camelCase}: video.playsInline = true.
Without
playsinline, you can detect the takeover: iOS fires a non-standardwebkitbeginfullscreenevent on the element {Không cóplaysinline, bạn có thể phát hiện việc bung màn hình: iOS bắn một event phi tiêu chuẩnwebkitbeginfullscreentrên element}. The demo below logs it so you can see the moment it happens {Demo bên dưới ghi log nó để bạn thấy khoảnh khắc đó}.
Rule 2 — autoplay needs muted {autoplay cần muted}
Modern browsers (Safari, Chrome, Firefox) block autoplay with sound. iOS is the strictest {Các trình duyệt hiện đại chặn autoplay có tiếng. iOS nghiêm nhất}. The only reliable autoplay recipe is all three together {Công thức autoplay đáng tin duy nhất là cả ba cùng lúc}:
<video
src="/clip.mp4"
autoplay
muted
playsinline
loop
></video>
The subtle part is muted {Điểm tinh tế là muted}: the autoplay policy checks the muted property, not just the attribute {chính sách autoplay kiểm tra property muted, không chỉ là attribute}. If you build the element in JS, set the property explicitly before calling play {Nếu bạn tạo element bằng JS, hãy set property một cách tường minh trước khi gọi play}:
const video = document.createElement('video');
video.src = '/clip.mp4';
video.playsInline = true;
video.muted = true; // ✅ property — the policy reads this
video.setAttribute('muted', ''); // attribute too, for markup parity
video.autoplay = true;
A common trap {Một cái bẫy hay gặp}: setting only video.setAttribute('muted', '') after the element exists can leave the property false, and autoplay silently fails {chỉ set video.setAttribute('muted','') sau khi element đã tồn tại có thể để property vẫn false, và autoplay thất bại trong im lặng}.
Rule 3 — play() returns a promise that can reject {play() trả về một promise có thể bị từ chối}
HTMLMediaElement.play() is asynchronous and returns a promise {HTMLMediaElement.play() là bất đồng bộ và trả về một promise}. When the browser refuses (unmuted autoplay, Low Power Mode, no user gesture), that promise rejects with NotAllowedError — it does not throw synchronously {Khi trình duyệt từ chối (autoplay chưa muted, chế độ tiết kiệm pin, không có gesture), promise đó bị reject với NotAllowedError — nó không throw đồng bộ}. If you ignore it, the video just sits there and you get an unhandled rejection in the console {Nếu bạn bỏ qua, video chỉ nằm im và bạn nhận một unhandled rejection trong console}.
Always handle it, and fall back to a tap-to-play affordance {Luôn xử lý nó, và dự phòng bằng một nút chạm-để-phát}:
async function startVideo(video, playButton) {
try {
await video.play();
playButton.hidden = true; // autoplay worked
} catch (err) {
// NotAllowedError → the browser blocked it. Show a tap target;
// a real user gesture is the one thing it will always accept.
playButton.hidden = false;
playButton.addEventListener('click', () => video.play(), { once: true });
}
}
The golden rule {Quy tắc vàng}: a genuine user gesture (tap/click) can do anything — play with sound, go fullscreen, unmute {một cú gesture thật của người dùng (chạm/click) làm được mọi thứ — phát có tiếng, fullscreen, bỏ mute}. Everything automatic is restricted {Mọi thứ tự động đều bị hạn chế}.
The gotchas that waste an afternoon {Những cái bẫy ngốn cả buổi chiều}
- Low Power Mode {Chế độ tiết kiệm pin} — iOS blocks even muted autoplay when Low Power Mode is on. There is no override; your tap-to-play fallback is the only answer {iOS chặn cả autoplay đã muted khi bật tiết kiệm pin. Không có cách ép; nút chạm-để-phát là lối thoát duy nhất}.
mutedset too late {mutedset quá trễ} — covered above; the property must be true beforeplay(){property phảitruetrướcplay()}.- The reduced-data / cellular setting {thiết lập tiết kiệm dữ liệu / mạng di động} — Safari may refuse to preload over cellular; use
preload="metadata"orpreload="none"and play on demand {Safari có thể từ chối preload qua mạng di động; dùngpreload="metadata"hoặcpreload="none"rồi phát theo yêu cầu}. - Wrong codec/container {sai codec/định dạng} — iOS needs H.264/HEVC in an MP4. A VP9/WebM-only file simply won’t decode {iOS cần H.264/HEVC trong MP4. File chỉ có VP9/WebM sẽ không giải mã được}.
- Inline needs
playsinlineeven withcontrols{inline vẫn cầnplaysinlinedù cócontrols} —controlsadds the scrubber but does not stop the fullscreen jump on play {controlsthêm thanh tua nhưng không chặn việc bung fullscreen khi phát}.
Try it — the live demo {Thử ngay — demo trực tiếp}
Toggle each attribute and watch the generated <video> tag, the playback status, and the event log {Bật/tắt từng attribute và xem thẻ <video> được sinh ra, trạng thái phát, và log sự kiện}. On a desktop browser everything plays inline; the iOS-specific behavior (the fullscreen takeover, the rejected promise) is best seen on a real iPhone {Trên desktop mọi thứ phát inline; hành vi riêng của iOS (bung fullscreen, promise bị từ chối) nên xem trên iPhone thật}.
Open the full demo {Mở demo đầy đủ}: /tools/ios-video-playsinline-demo/.
Things to try {Những thứ nên thử}:
- Uncheck
playsinline, Reload, press play() → on iPhone it goes fullscreen (webkitbeginfullscreenlogged) {Bỏplaysinline, Reload, bấm play() → trên iPhone nó bung fullscreen}. - Uncheck
muted, Reload → the autoplayplay()promise rejects withNotAllowedError(red in the log) {Bỏmuted, Reload → promise autoplay bị từ chối vớiNotAllowedError}. - Press the play() button yourself (a real tap) with sound on → it succeeds, because a user gesture is allowed {Tự bấm nút play() (một cú chạm thật) khi có tiếng → thành công, vì gesture của người dùng được phép}.
The copy-paste recipe {Công thức copy-paste}
A muted, looping background video that plays inline everywhere and degrades gracefully {Một video nền muted, loop, phát inline ở mọi nơi và xuống cấp êm ái}:
<video
class="hero-video"
src="/hero.mp4"
autoplay
muted
loop
playsinline
webkit-playsinline
preload="metadata"
poster="/hero-poster.jpg"
></video>
<button class="hero-play" hidden>Tap to play</button>
const video = document.querySelector('.hero-video');
const playButton = document.querySelector('.hero-play');
// muted must be true as a PROPERTY for the autoplay policy.
video.muted = true;
video.play().catch(() => {
// Blocked (e.g. Low Power Mode). Reveal a tap target — a gesture always works.
playButton.hidden = false;
playButton.addEventListener('click', () => {
playButton.hidden = true;
video.play();
}, { once: true });
});
Checklist before you ship {Checklist trước khi ship}
-
playsinline(andwebkit-playsinline) on every inline video {trên mọi video inline} -
mutedset as a property when you need autoplay {set như một property khi cần autoplay} -
autoplay+loopfor background video {cho video nền} -
.play()wrapped intry/catch(or.catch()) with a tap-to-play fallback {bọc trongtry/catchvới dự phòng chạm-để-phát} - MP4 with H.264/HEVC {MP4 với H.264/HEVC}
-
preload="metadata"to respect data limits {để tôn trọng giới hạn dữ liệu} - Tested on a real iPhone, including with Low Power Mode on {Đã test trên iPhone thật, kể cả khi bật tiết kiệm pin}
iOS isn’t being difficult for fun {iOS không khó tính cho vui}: every rule maps to a user complaint it once received — surprise fullscreen, autoplaying noise, drained batteries {mỗi luật ứng với một lời than phiền nó từng nhận — fullscreen bất ngờ, tiếng ồn tự phát, cạn pin}. Satisfy the rules and your video behaves identically everywhere {Thỏa mãn các luật và video của bạn sẽ hành xử giống nhau ở mọi nơi}.
References {Tham khảo}: