jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-standard webkitbeginfullscreen event 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ẩn webkitbeginfullscreen trê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}.
  • muted set too late {muted set quá trễ} — covered above; the property must be true before play() {property phải true trước play()}.
  • 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" or preload="none" and play on demand {Safari có thể từ chối preload qua mạng di động; dùng preload="metadata" hoặc preload="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 playsinline even with controls {inline vẫn cần playsinline dù có controls} — controls adds the scrubber but does not stop the fullscreen jump on play {controls thê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ử}:

  1. Uncheck playsinline, Reload, press play() → on iPhone it goes fullscreen (webkitbeginfullscreen logged) {Bỏ playsinline, Reload, bấm play() → trên iPhone nó bung fullscreen}.
  2. Uncheck muted, Reload → the autoplay play() promise rejects with NotAllowedError (red in the log) {Bỏ muted, Reload → promise autoplay bị từ chối với NotAllowedError}.
  3. 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 (and webkit-playsinline) on every inline video {trên mọi video inline}
  • muted set as a property when you need autoplay {set như một property khi cần autoplay}
  • autoplay + loop for background video {cho video nền}
  • .play() wrapped in try/catch (or .catch()) with a tap-to-play fallback {bọc trong try/catch vớ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}: