From 42d75f2542988872055400c59a547d8a6bd8260f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20C=C3=B4ng=20Huy?= Date: Wed, 5 Aug 2026 04:53:37 +0700 Subject: [PATCH] =?UTF-8?q?feat(AnimeVietSub):=20add=20a=20privacy=20butto?= =?UTF-8?q?n=20and=20change=20"Ch=E1=BA=BF=20=C4=90=E1=BB=99=20Ri=C3=AAng?= =?UTF-8?q?=20T=C6=B0"=20to=20"Privacy=20Overwrite=20Button"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- websites/A/AnimeVietSub/iframe.ts | 52 ++++++ websites/A/AnimeVietSub/metadata.json | 8 +- websites/A/AnimeVietSub/presence.ts | 141 ++++++++++++++++- websites/A/AnimeVietSub/privacy.ts | 84 ++++++++++ websites/A/AnimeVietSub/privacyUi.ts | 218 ++++++++++++++++++++++++++ 5 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 websites/A/AnimeVietSub/privacy.ts create mode 100644 websites/A/AnimeVietSub/privacyUi.ts diff --git a/websites/A/AnimeVietSub/iframe.ts b/websites/A/AnimeVietSub/iframe.ts index 32344a0f750e..529d33f6c896 100644 --- a/websites/A/AnimeVietSub/iframe.ts +++ b/websites/A/AnimeVietSub/iframe.ts @@ -1,8 +1,60 @@ +import type { PrivacyCommandMessage } from './privacy.js' +import { + isPrivacyStateMessage, + PRIVACY_MESSAGE_SOURCE, +} from './privacy.js' +import { + ensurePrivacyButton, + removePrivacyButton, +} from './privacyUi.js' + const iframe = new iFrame() +let privateMode = false +let privacyButtonShown = false +let hasPrivacyState = false +let lastStateRequest = 0 + +function sendPrivacyCommand(type: PrivacyCommandMessage['type']): void { + window.top?.postMessage({ source: PRIVACY_MESSAGE_SOURCE, type }, '*') +} + +function togglePrivacy(): void { + if (!privacyButtonShown) + return + + privateMode = !privateMode + ensurePrivacyButton(document, privateMode, togglePrivacy) + sendPrivacyCommand('toggle') +} + +function syncPrivacyButton(): void { + if (privacyButtonShown) + ensurePrivacyButton(document, privateMode, togglePrivacy) + else + removePrivacyButton(document) +} + +window.addEventListener('message', (event) => { + if (event.source !== window.top || !isPrivacyStateMessage(event.data)) + return + + privateMode = event.data.privateMode + privacyButtonShown = event.data.privacyButtonShown + hasPrivacyState = true + syncPrivacyButton() +}) iframe.on('UpdateData', async () => { const video = document.querySelector('video') if (video && !Number.isNaN(video.duration)) { + if (hasPrivacyState) { + syncPrivacyButton() + } + else if (Date.now() - lastStateRequest > 2000) { + lastStateRequest = Date.now() + sendPrivacyCommand('request-state') + } + iframe.send({ duration: video.duration, currTime: video.currentTime, diff --git a/websites/A/AnimeVietSub/metadata.json b/websites/A/AnimeVietSub/metadata.json index ff5ce9d6fa3c..ade5519e3587 100644 --- a/websites/A/AnimeVietSub/metadata.json +++ b/websites/A/AnimeVietSub/metadata.json @@ -32,10 +32,10 @@ "value": true }, { - "id": "privateMode", - "title": "Chế Độ Riêng Tư", - "icon": "fas fa-user-secret", - "value": false + "id": "privacy-shown", + "title": "Privacy Overwrite Button", + "icon": "fad fa-eye-low-vision", + "value": true } ] } diff --git a/websites/A/AnimeVietSub/presence.ts b/websites/A/AnimeVietSub/presence.ts index a2366fff6d2f..8a40621c4253 100644 --- a/websites/A/AnimeVietSub/presence.ts +++ b/websites/A/AnimeVietSub/presence.ts @@ -1,9 +1,128 @@ import { ActivityType, Assets, getTimestamps } from 'premid' +import { + getEpisodeKey, + getEpisodePrivacy, + isPrivacyCommandMessage, + parsePrivacyOverrides, + PRIVACY_MESSAGE_SOURCE, + toggleEpisodePrivacy, +} from './privacy.js' +import { ensurePrivacyButton, removePrivacyButton } from './privacyUi.js' const presence = new Presence({ clientId: '1264754447276310599', }) +const privacyStorageKey = 'pmdAnimeVietSubPrivacyOverrides' +const privacyFrames = new Set() +let currentEpisodeKey = '' +let currentEpisodePrivacy = false +let currentPrivacyButtonShown = true +let lastBroadcastEpisodeKey = '' +let lastBroadcastPrivacy: boolean | null = null +let lastBroadcastPrivacyButtonShown: boolean | null = null + +function readPrivacyOverrides() { + return parsePrivacyOverrides(localStorage.getItem(privacyStorageKey)) +} + +function sendPrivacyState( + target: Window, + privateMode: boolean, + privacyButtonShown: boolean, +): void { + target.postMessage({ + source: PRIVACY_MESSAGE_SOURCE, + type: 'state', + privateMode, + privacyButtonShown, + }, '*') +} + +function broadcastPrivacyState( + privateMode: boolean, + privacyButtonShown: boolean, + force = false, +): void { + if (!force + && lastBroadcastEpisodeKey === currentEpisodeKey + && lastBroadcastPrivacy === privateMode + && lastBroadcastPrivacyButtonShown === privacyButtonShown) { + return + } + + lastBroadcastEpisodeKey = currentEpisodeKey + lastBroadcastPrivacy = privateMode + lastBroadcastPrivacyButtonShown = privacyButtonShown + for (const frame of privacyFrames) { + try { + sendPrivacyState(frame, privateMode, privacyButtonShown) + } + catch { + privacyFrames.delete(frame) + } + } +} + +function toggleCurrentEpisodePrivacy(): void { + const overrides = toggleEpisodePrivacy( + false, + currentEpisodeKey, + readPrivacyOverrides(), + ) + localStorage.setItem(privacyStorageKey, JSON.stringify(overrides)) + currentEpisodePrivacy = getEpisodePrivacy( + false, + currentEpisodeKey, + overrides, + ) + if (currentPrivacyButtonShown) + ensurePrivacyButton(document, currentEpisodePrivacy, toggleCurrentEpisodePrivacy) + broadcastPrivacyState(currentEpisodePrivacy, currentPrivacyButtonShown, true) +} + +async function sendInitialPrivacyState(target: Window): Promise { + currentPrivacyButtonShown = await presence.getSetting('privacy-shown') + currentEpisodeKey = getEpisodeKey(document.location.pathname, document.location.search) + currentEpisodePrivacy = getEpisodePrivacy( + false, + currentEpisodeKey, + readPrivacyOverrides(), + ) + sendPrivacyState(target, currentEpisodePrivacy, currentPrivacyButtonShown) +} + +window.addEventListener('message', (event) => { + if (!isPrivacyCommandMessage(event.data) || !event.source || !isWatchPage()) + return + + const target = event.source as Window + privacyFrames.add(target) + + if (event.data.type === 'request-state') { + void sendInitialPrivacyState(target) + return + } + + currentEpisodeKey = getEpisodeKey(document.location.pathname, document.location.search) + + if (currentPrivacyButtonShown) { + const overrides = toggleEpisodePrivacy( + false, + currentEpisodeKey, + readPrivacyOverrides(), + ) + localStorage.setItem(privacyStorageKey, JSON.stringify(overrides)) + } + + currentEpisodePrivacy = getEpisodePrivacy( + false, + currentEpisodeKey, + readPrivacyOverrides(), + ) + sendPrivacyState(target, currentEpisodePrivacy, currentPrivacyButtonShown) +}) + let browsingTimestamp = Number.parseInt(sessionStorage.getItem('PMD_browsing_time') || '0', 10) if (!browsingTimestamp || Number.isNaN(browsingTimestamp)) { browsingTimestamp = Math.floor(Date.now() / 1000) @@ -153,9 +272,23 @@ presence.on('UpdateData', async () => { const { pathname, href } = document.location const buttons = await presence.getSetting('buttons') - const privateMode = await presence.getSetting('privateMode') + const privacyButtonShown = await presence.getSetting('privacy-shown') + const watchPage = isWatchPage() + const detailPage = isDetailPage() + + currentPrivacyButtonShown = privacyButtonShown + currentEpisodeKey = getEpisodeKey(pathname, document.location.search) + currentEpisodePrivacy = watchPage + ? getEpisodePrivacy(false, currentEpisodeKey, readPrivacyOverrides()) + : false + + if (watchPage) { + if (privacyButtonShown) + ensurePrivacyButton(document, currentEpisodePrivacy, toggleCurrentEpisodePrivacy) + else + removePrivacyButton(document) + broadcastPrivacyState(currentEpisodePrivacy, privacyButtonShown) - if (isWatchPage()) { const title = getAnimeTitle() const episode = getEpisodeName() const poster = getAnimePoster() @@ -246,7 +379,7 @@ presence.on('UpdateData', async () => { ] } } - else if (isDetailPage()) { + else if (detailPage) { const title = getAnimeTitle() const poster = getAnimePoster() @@ -302,7 +435,7 @@ presence.on('UpdateData', async () => { } } - if (privateMode && (isWatchPage() || isDetailPage())) { + if (currentEpisodePrivacy && (watchPage || detailPage)) { presenceData.details = 'Đang xem Anime' delete presenceData.state presenceData.largeImageKey = ActivityAssets.Logo diff --git a/websites/A/AnimeVietSub/privacy.ts b/websites/A/AnimeVietSub/privacy.ts new file mode 100644 index 000000000000..c33285fa8cfa --- /dev/null +++ b/websites/A/AnimeVietSub/privacy.ts @@ -0,0 +1,84 @@ +export type PrivacyOverrides = Record + +export const PRIVACY_MESSAGE_SOURCE = 'premid-animevietsub-privacy' + +export interface PrivacyCommandMessage { + source: typeof PRIVACY_MESSAGE_SOURCE + type: 'request-state' | 'toggle' +} + +export interface PrivacyStateMessage { + source: typeof PRIVACY_MESSAGE_SOURCE + type: 'state' + privateMode: boolean + privacyButtonShown: boolean +} + +const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']) + +export function isPrivacyCommandMessage(value: unknown): value is PrivacyCommandMessage { + if (!value || typeof value !== 'object') + return false + + const message = value as Partial + return message.source === PRIVACY_MESSAGE_SOURCE + && (message.type === 'request-state' || message.type === 'toggle') +} + +export function isPrivacyStateMessage(value: unknown): value is PrivacyStateMessage { + if (!value || typeof value !== 'object') + return false + + const message = value as Partial + return message.source === PRIVACY_MESSAGE_SOURCE + && message.type === 'state' + && typeof message.privateMode === 'boolean' + && typeof message.privacyButtonShown === 'boolean' +} + +export function parsePrivacyOverrides(raw: string | null): PrivacyOverrides { + try { + const parsed: unknown = JSON.parse(raw ?? '{}') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + return {} + + const overrides: PrivacyOverrides = {} + for (const [key, value] of Object.entries(parsed)) { + if (unsafeKeys.has(key) || typeof value !== 'boolean') + return {} + overrides[key] = value + } + return overrides + } + catch { + return {} + } +} + +export function getEpisodeKey(pathname: string, search: string): string { + const normalizedPath = pathname.replace(/\/+$/, '') || '/' + const episode = new URLSearchParams(search).get('tap') + + return episode + ? `${normalizedPath}?tap=${encodeURIComponent(episode)}` + : normalizedPath +} + +export function getEpisodePrivacy( + globalPrivacy: boolean, + episodeKey: string, + overrides: PrivacyOverrides, +): boolean { + return overrides[episodeKey] ?? globalPrivacy +} + +export function toggleEpisodePrivacy( + globalPrivacy: boolean, + episodeKey: string, + overrides: PrivacyOverrides, +): PrivacyOverrides { + return { + ...overrides, + [episodeKey]: !getEpisodePrivacy(globalPrivacy, episodeKey, overrides), + } +} diff --git a/websites/A/AnimeVietSub/privacyUi.ts b/websites/A/AnimeVietSub/privacyUi.ts new file mode 100644 index 000000000000..47524bafc30b --- /dev/null +++ b/websites/A/AnimeVietSub/privacyUi.ts @@ -0,0 +1,218 @@ +const buttonId = 'pmd-animevietsub-privacy' +const styleId = 'pmd-animevietsub-privacy-style' + +const controlBarSelectors = [ + '.vjs-control-bar', + '.jw-controlbar .jw-button-container', + '.plyr__controls', + '.dplayer-icons-right', + '.art-control-right', + '.media-control-right-panel', + '.mejs__controls', + '.shaka-controls-button-panel', +] + +const settingsControlSelectors = [ + '.vjs-settings-button', + '.jw-icon-settings', + '[data-plyr="settings"]', + '.dplayer-setting', + '.art-control-setting', + '.media-control-button[data-settings]', + '.mejs__settings-button', + '.shaka-overflow-menu-button', + '[aria-label*="Settings"]', + '[title*="Settings"]', + '[aria-label*="Cài đặt"]', + '[title*="Cài đặt"]', +] + +const pictureInPictureControlSelectors = [ + '.vjs-picture-in-picture-control', + '.jw-icon-pip', + '[data-plyr="pip"]', + '.dplayer-pip-icon', + '.art-control-pip', + '.media-control-button[data-pip]', + '.mejs__picture-in-picture-button', + '.shaka-pip-button', + '[aria-label*="Picture in Picture"]', + '[aria-label*="Picture-in-Picture"]', + '[title*="Picture in Picture"]', + '[title*="Picture-in-Picture"]', +] + +const actionControlClass = /(?:^|[-_])(?:settings?|picture|pip|fullscreen|quality|captions?|subtitles?|overflow|menu)(?:$|[-_])/i + +const eyeIcon = ` + + ` + +function ensureStyles(document: Document): void { + if (document.getElementById(styleId)) + return + + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + .pmd-avs-privacy-host { position: relative !important; } + .pmd-avs-privacy-button { + align-items: center; box-sizing: border-box; color: inherit; cursor: pointer; + display: inline-flex; justify-content: center; position: relative; + } + .pmd-avs-privacy-button:focus-visible { outline: 2px solid #fff; outline-offset: -3px; } + .pmd-avs-privacy-button svg { + display: none; height: 100%; pointer-events: none; transform: scale(.6); width: 100%; + } + .pmd-avs-privacy-button[aria-pressed="false"] svg[data-private="false"], + .pmd-avs-privacy-button[aria-pressed="true"] svg[data-private="true"] { display: block; } + .pmd-avs-privacy-button--fallback { + background: rgba(0, 0, 0, .58); border: 0; border-radius: 3px; bottom: 8px; + color: #fff; height: 36px; padding: 7px; position: absolute; right: 48px; + width: 40px; z-index: 2147483646; + } + .pmd-avs-privacy-button--fallback svg { height: 22px; transform: none; width: 22px; } + ` + document.head?.append(style) +} + +function getPlayerHost(video: HTMLVideoElement): HTMLElement | null { + return video.closest( + '.video-js, .jwplayer, .plyr, .dplayer, .artplayer-app, .media-player, .mejs__container, .shaka-video-container, [class*="player"], [id*="player"]', + ) ?? video.parentElement +} + +function getControlBar(document: Document, host: HTMLElement | null): HTMLElement | null { + for (const selector of controlBarSelectors) { + const controls = host?.querySelector(selector) + ?? document.querySelector(selector) + if (controls) + return controls + } + return null +} + +function findControl( + controls: HTMLElement, + selectors: string[], +): HTMLElement | null { + for (const selector of selectors) { + const control = controls.querySelector(selector) + if (control) + return control + } + return null +} + +function getDirectControl( + controls: HTMLElement, + control: HTMLElement | null, +): HTMLElement | null { + while (control?.parentElement && control.parentElement !== controls) + control = control.parentElement + return control?.parentElement === controls ? control : null +} + +function mountPrivacyButton( + controls: HTMLElement | null, + mount: HTMLElement, + button: HTMLButtonElement, +): HTMLElement | null { + if (!controls) { + if (button.parentElement !== mount) + mount.append(button) + return null + } + + const settingsControl = findControl(controls, settingsControlSelectors) + const pictureInPictureControl = findControl( + controls, + pictureInPictureControlSelectors, + ) + const settings = getDirectControl(controls, settingsControl) + const pictureInPicture = getDirectControl(controls, pictureInPictureControl) + const nativeControl = settingsControl ?? pictureInPictureControl + + if (pictureInPicture) { + if (button.parentElement !== controls + || button.nextElementSibling !== pictureInPicture) { + controls.insertBefore(button, pictureInPicture) + } + return nativeControl + } + + const nextControl = settings?.nextElementSibling + if (nextControl && nextControl !== button) + controls.insertBefore(button, nextControl) + else if (button.parentElement !== controls) + controls.append(button) + return nativeControl +} + +function syncPrivacyButtonAppearance( + button: HTMLButtonElement, + nativeControl: HTMLElement | null, +): void { + const nativeClasses = nativeControl?.className + .split(/\s+/) + .filter(className => className && !actionControlClass.test(className)) + ?? [] + + button.className = [...new Set([ + ...nativeClasses, + 'pmd-avs-privacy-button', + ])].join(' ') +} + +export function setPrivacyButtonState(button: HTMLButtonElement, privateMode: boolean): void { + const label = 'Chế độ riêng tư' + + button.ariaPressed = String(privateMode) + button.ariaLabel = label + button.title = label +} + +export function removePrivacyButton(document: Document): void { + document.getElementById(buttonId)?.remove() +} + +export function ensurePrivacyButton( + document: Document, + privateMode: boolean, + onToggle: () => void, +): HTMLButtonElement | null { + const video = document.querySelector('video') + if (!video) + return null + + ensureStyles(document) + const host = getPlayerHost(video) + const controls = getControlBar(document, host) + const mount = controls ?? host + if (!mount) + return null + + host?.classList.add('pmd-avs-privacy-host') + let button = document.getElementById(buttonId) as HTMLButtonElement | null + if (!button) { + button = document.createElement('button') + button.id = buttonId + button.type = 'button' + button.innerHTML = eyeIcon + button.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + onToggle() + }) + } + + const nativeControl = mountPrivacyButton(controls, mount, button) + syncPrivacyButtonAppearance(button, nativeControl) + button.classList.toggle('pmd-avs-privacy-button--fallback', !nativeControl) + setPrivacyButtonState(button, privateMode) + return button +}