Xây dựng tiện ích Chrome xem YouTube cùng bạn bè chỉ trong một buổi chiều
Tôi đã tạo ra một tiện ích Chrome tên là YPlay giúp đồng bộ phát lại video YouTube giữa hai người dùng ở xa. Dưới 1.200 dòng TypeScript, sử dụng Supabase Realtime mà không cần xây dựng backend riêng biệt, dự án đã hoàn tất chỉ trong một buổi chiều làm việc.

Bạn và một người thân ở hai thành phố khác nhau muốn cùng xem một video trên YouTube. Bạn muốn phát video khi họ phát, dừng lại khi họ dừng, hoặc chuyển video cùng lúc. Có nhiều dịch vụ hỗ trợ việc này, nhưng tất cả đều yêu cầu mở một trang web riêng, copy-paste liên kết, hoặc chia sẻ màn hình qua cuộc gọi.
Nhưng nếu bạn chỉ cần... bật một công tắc (toggle) bên cạnh tên người bạn của mình, và các trình duyệt sẽ tự động đồng bộ thì sao?
Đó chính là điều mà YPlay làm được. Đây là một tiện ích mở rộng Chrome được hỗ trợ bởi Supabase Realtime, được xây dựng hoàn toàn trong một lần ngồi. Với chưa đầy 1.200 dòng TypeScript, chỉ một phụ thuộc bên ngoài ngoài React và không cần hạ tầng backend nào khác ngoài một dự án Supabase miễn phí.
Giao diện tiện ích
Vấn đề của việc "Chỉ cần xem cùng nhau"
Mọi công cụ xem cùng nhau (watch-together) mà tôi thử đều gặp phải cùng một sự khó chịu: phải rời khỏi YouTube, mở một trang bên thứ ba, tạo phòng, chia sẻ liên kết và hy vọng mọi người tham gia trước khi video bắt đầu. Đến khi bạn "đồng bộ" được với nhau, thì sự hào hứng ban đầu đã chết mất.
Tôi muốn một cái gì đó hoạt động ngay bên trong YouTube. Không cần chuyển đổi ngữ cảnh. Không cần mã phòng để copy-paste. Chỉ cần mở YouTube, bật người bạn lên và xem cùng nhau. Điều đó đồng nghĩa với việc phải xây dựng một Chrome extension.
OAuth trong Extension khá "kỳ lạ"
Các tiện ích mở rộng không thể sử dụng các chuyển hướng trình duyệt bình thường cho OAuth. Không có thanh địa chỉ URL, không có điều hướng trang. Chrome cung cấp chrome.identity.launchWebAuthFlow, thứ mở ra một popup được kiểm soát để thực hiện quy trình OAuth và trả về URL cuối cùng cùng với các token.
Mẹo ở đây là telling Supabase là đừng chuyển hướng trình duyệt, vì bên trong một popup của tiện ích, không có chỗ nào để chuyển hướng về:
const { data } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: chrome.identity.getRedirectURL(),
skipBrowserRedirect: true,
},
})
const responseUrl = await chrome.identity.launchWebAuthFlow({
url: data.url,
interactive: true,
})
Các phiên đăng nhập được lưu trữ vào chrome.storage.local thay vì localStorage, bởi vì DOM của popup sẽ bị hủy mỗi khi bạn đóng nó. Nếu quên điều này, người dùng của bạn sẽ phải đăng nhập lại từ đầu mỗi khi click vào icon tiện ích.
Từ Room Code đến danh sách Bạn bè
Phiên bản đầu tiên có ô nhập mã phòng. Gõ mã sáu ký tự để tham gia. Nó hoạt động, nhưng trải nghiệm người dùng (UX) rất tệ. Không ai muốn phối hợp các mã qua tin nhắn chỉ để xem một video.
Vì vậy, tôi đã thay thế nó bằng hệ thống bạn bè. Gửi lời mời kết bạn qua email. Chấp nhận nó. Sau đó bật công tắc bên cạnh tên họ. Xong.
Mã phòng vẫn tồn tại dưới nền, được xác định deterministically từ cả hai địa chỉ email:
function generateRoomCode(email1: string, email2: string): string {
const sorted = [email1.toLowerCase(), email2.toLowerCase()].sort()
const str = sorted.join(':')
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
}
return Math.abs(hash).toString(36).toUpperCase().padEnd(6, '0').substring(0, 6)
}
Hai người giống nhau luôn nhận được cùng một phòng. Không cần điều phối máy chủ. Không có vấn đề "ai sẽ tạo phòng?". Bật công tắc lên, bạn đã kết nối.
Lời mời kết bạn dựa trên email có chủ đích. Bạn có thể mời những người chưa đăng ký. Khi họ cài đặt tiện ích và đăng nhập, lời mời đang chờ họ.
Động cơ đồng bộ hóa (The Sync Engine)
Background service worker đăng ký kênh broadcast Supabase Realtime cho phòng đang hoạt động. Sáu sự kiện xử lý mọi thứ:
| Sự kiện | Chức năng |
|---|---|
sync-video | Điều hướng đến URL video mới |
sync-play | Tiếp tục phát lại |
sync-pause | Tạm dừng phát lại |
sync-seek | Nhảy đến một mốc thời gian cụ thể |
open-youtube | Mở YouTube nếu chưa mở |
youtube-status | Báo cáo liệu YouTube có đang hoạt động hay không |
Mọi sự kiện đều mang theo một machine_id, một UUID ổn định cho mỗi trình duyệt. Đây là cách tiện ích bỏ qua các broadcast của chính nó. Nếu không có nó, việc dừng video của bạn sẽ phát đi lệnh dừng, lệnh đó quay lại bạn, và... bạn hiểu ý tôi rồi đấy.
Sơ đồ luồng hoạt động
Không cần máy chủ WebSocket tùy chỉnh. Không cần hạ tầng tín hiệu. Supabase Realtime xử lý tất cả. Toàn bộ "backend" chỉ là một dự án Supabase với hai bảng và một số chính sách RLS.
Vấn đề khó nhất: Chặn Echo (Echo Suppression)
Đây là vấn đề mà không ai cảnh báo bạn khi xây dựng tính năng đồng bộ thời gian thực: mọi hành động đều gây ra một phản ứng trông giống như một hành động mới.
Khi người dùng bên xa nói "dừng", content script gọi video.pause(). Điều này kích hoạt sự kiện pause gốc. Content script đang lắng nghe các sự kiện pause để phát đi lệnh broadcast. Vì vậy, nó phát đi lệnh sync-pause ngược lại phòng. Video của người kia lại dừng thêm một lần nữa. Vòng lặp vô tận.
Giải pháp là một tập hợp các cờ chặn (suppression flags):
let suppressPause = false
// Lệnh từ xa đến:
if (msg.type === 'pause-video') {
suppressPause = true
video.pause()
setTimeout(() => { suppressPause = false }, 500)
}
// Sự kiện nội bộ kích hoạt:
function onPause() {
if (suppressPause) return // nuốt chửng cái echo
chrome.runtime.sendMessage({ type: 'video-paused', videoId: vid })
}
Cùng một mẫu cho play, seek và điều hướng video. Mỗi cái có một cờ riêng. Mỗi cờ tự động tắt sau 500ms. Nó không thanh lịch lắm, nhưng cực kỳ đáng tin cậy.
Echo ở cấp độ video cần một cơ chế riêng. Khi sync-video điều hướng đến URL mới, content script báo cáo URL đó như một điều hướng nội bộ. Background lưu trữ video ID đã đồng bộ lần cuối và nuốt chửng bản sao. Các sự kiện seek được debounce ở mức 300ms vì YouTube kích hoạt nhiều sự kiện seeked từ một cú nhấp chuột duy nhất vào thanh tiến trình.
Vấn đề đa-tab
Điều gì xảy ra khi bạn mở ba tab YouTube?
Giao diện đa-tab
Nếu không xử lý, mọi lệnh đồng bộ sẽ đi đến một tab ngẫu nhiên. Hoặc tệ hơn là cả ba tab cùng lúc. Giải pháp là một lớp phủ chọn tab (tab-selector): một modal tối toàn màn hình được chèn vào mỗi tab YouTube hỏi "Sử dụng tab này để phát lại đồng bộ?".
Background duy trì ID tab đã chọn và định tuyến mọi thao tác thông qua nó:
async function withSelectedTab(callback: (tabId: number) => void) {
const selectedTabId = await getSelectedTab()
if (selectedTabId !== null) {
callback(selectedTabId)
return
}
const tabs = await chrome.tabs.query({ url: '*://*.youtube.com/*' })
if (tabs.length === 1 && tabs[0].id !== undefined) {
await setSelectedTab(tabs[0].id)
callback(tabs[0].id)
return
}
pendingCallbacks.push(callback)
showTabSelectorOverlays()
}
Một tab? Tự động chọn. Nhiều tab? Bạn chọn. Tab được chọn đóng? Lựa chọn sẽ xóa và tự động giải quyết nếu chỉ còn một. Các lệnh đến trước khi tab được chọn sẽ được xếp hàng và thực thi sau khi bạn chọn.
Giữ Service Worker Manifest V3 sống sót
Manifest V3 đã "giết chết" các trang background liên tục. Service workers bị chấm dứt sau khoảng 30秒 không hoạt động. Một service worker chết có nghĩa là kết nối Realtime cũng chết. Người bạn dừng video của họ và không có gì xảy ra ở phía bạn.
Giải pháp là một nhịp tim chrome.alarms:
chrome.alarms.create('yplay-keepalive', { periodInMinutes: 0.4 })
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'yplay-keepalive') {
ensureConnected()
broadcastYouTubeStatus()
}
})
Mỗi 24 giây, báo thức sẽ kích hoạt. Nếu kênh bị ngắt (nó sẽ bị ngắt), ensureConnected() sẽ đăng ký lại. Đây là bốn dòng mã quan trọng nhất trong toàn bộ tiện ích. Không có chúng, tính năng đồng bộ sẽ âm thầm "chết" sau 30 giây không hoạt động.
Lưu ý: Chrome giới hạn
periodInMinutestối thiểu là 1 phút cho các tiện ích đã xuất bản. Khoảng thời gian 0.4 phút chỉ hoạt động trong quá trình phát triển cục bộ. Trong môi trường production, báo thức này kích hoạt mỗi 60 giây thay vì 24. Điều đó vẫn ổn,chrome.alarmsđánh thức service worker ngay cả khi nó đã bị chấm dứt, nênensureConnected()sẽ đăng ký lại và đồng bộ sẽ tiếp tục trong vòng một phút.
Bài học kinh nghiệm
- Bắt đầu từ sự tương tác, không phải hạ tầng. Dự án này không bắt đầu bằng "hãy thiết lập một máy chủ WebSocket". Nó bắt đầu bằng "làm thế nào nếu tôi chỉ cần bật một công tắc?". Trải nghiệm người dùng đơn giản nhất chi phối mọi quyết định kỹ thuật.
- Supabase Realtime bị đánh giá thấp đối với tiện ích. Các kênh broadcast cung cấp mô hình pub/sub mà không cần quản lý kết nối. Kết hợp với
chrome.alarmsđể duy trì kết nối, nó là một xương sống thời gian thực hoàn chỉnh với chi phí hạ tầng bằng không. - Echo suppression là vấn đề thực sự. Đồng bộ thì dễ. Phát sự kiện, thực thi nó từ xa. Phần khó là đảm bảo các thực thi từ xa không kích hoạt các phát sóng mới. Mọi hệ thống đồng bộ thời gian thực cuối cùng đều phải tái phát minh chiếc xe này.
- Chrome extensions có nhiều góc cạnh sắc. OAuth không có chuyển hướng. Phiên không có localStorage. Background scripts chết sau 30 giây. Mỗi cái là một bài đăng blog chờ được viết, và mỗi cái có chính xác một câu trả lời đúng nằm chôn trong tài liệu.
Kết luận
Mọi lập trình viên đều có khoảnh khắc "tại sao cái này chưa tồn tại?". Thông thường, sau đó là nhận ra nó đã tồn tại, chỉ không phải theo cách bạn muốn. YPlay bắt đầu vì tôi muốn xem YouTube với một người bạn mà không phải rời khỏi YouTube. Các công nghệ (Supabase, Manifest V3, content scripts) tất cả đều xuất phát từ một ràng buộc duy nhất đó.
Đôi khi kiến trúc tốt nhất là thứ bạn có thể xây dựng trước bữa tối.
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
