first commit
This commit is contained in:
157
public/userscripts/api-media-player-open-in-mpv.user.js
Normal file
157
public/userscripts/api-media-player-open-in-mpv.user.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// ==UserScript==
|
||||
// @name API Media Player: Open in mpv
|
||||
// @namespace https://local.api-media-player
|
||||
// @version 0.1.0
|
||||
// @description Redirect direct media playback from this app into mpv instead of playing inside the browser.
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
// @inject-into page
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
(() => {
|
||||
const RECENT_OPEN_WINDOW_MS = 1500
|
||||
const DIRECT_MEDIA_EXTENSION = /\.(mp4|m4v|mkv|webm|mov|mp3|m4a|flac|ogg|opus|wav|aac)(?:$|[?#])/i
|
||||
const PRIVATE_IPV4_HOST = /^(?:10\.|127\.|192\.168\.|172\.(?:1[6-9]|2\d|3[0-1])\.)/
|
||||
const LOCAL_HOST_SUFFIX = /(?:\.local|\.lan)$/i
|
||||
|
||||
let lastOpenedUrl = ''
|
||||
let lastOpenedAt = 0
|
||||
|
||||
function isAllowedHost(hostname) {
|
||||
return hostname === 'localhost'
|
||||
|| hostname === '[::1]'
|
||||
|| PRIVATE_IPV4_HOST.test(hostname)
|
||||
|| LOCAL_HOST_SUFFIX.test(hostname)
|
||||
}
|
||||
|
||||
function getMediaUrlFromElement(element) {
|
||||
if (!element) return ''
|
||||
return element.currentSrc
|
||||
|| element.src
|
||||
|| element.getAttribute('src')
|
||||
|| element.querySelector('source[src]')?.getAttribute('src')
|
||||
|| ''
|
||||
}
|
||||
|
||||
function isDirectMediaUrl(rawUrl) {
|
||||
if (!rawUrl) return false
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, location.href)
|
||||
if (!/^https?:$/i.test(url.protocol)) return false
|
||||
|
||||
return DIRECT_MEDIA_EXTENSION.test(url.pathname)
|
||||
|| /\/get_files\/file$/i.test(url.pathname)
|
||||
|| url.searchParams.has('file_id')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUrlSafeBase64(value) {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
let binary = ''
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function looksLikeVideo(url, element) {
|
||||
if (element?.tagName === 'VIDEO') return true
|
||||
|
||||
try {
|
||||
const pathname = new URL(url, location.href).pathname
|
||||
return /\.(mp4|m4v|mkv|webm|mov)$/i.test(pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function buildDesktopMpvUrl(url, title) {
|
||||
const encodedUrl = encodeUrlSafeBase64(url)
|
||||
const encodedTitle = title ? encodeUrlSafeBase64(title) : ''
|
||||
const query = encodedTitle ? `?v_title=${encodedTitle}` : ''
|
||||
return `mpv-handler://play/${encodedUrl}/${query}`
|
||||
}
|
||||
|
||||
function buildAndroidMpvUrl(url, element) {
|
||||
try {
|
||||
const parsed = new URL(url, location.href)
|
||||
const scheme = parsed.protocol.replace(':', '') || 'https'
|
||||
const intentPath = `${parsed.host}${parsed.pathname}${parsed.search}`
|
||||
const mimeType = looksLikeVideo(url, element) ? 'video/*' : 'audio/*'
|
||||
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function buildExternalPlayerUrl(url, title, element) {
|
||||
if (/Android/i.test(navigator.userAgent || '')) {
|
||||
return buildAndroidMpvUrl(url, element)
|
||||
}
|
||||
|
||||
return buildDesktopMpvUrl(url, title)
|
||||
}
|
||||
|
||||
function openInExternalPlayer(url, element) {
|
||||
const now = Date.now()
|
||||
if (url === lastOpenedUrl && now - lastOpenedAt < RECENT_OPEN_WINDOW_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
lastOpenedUrl = url
|
||||
lastOpenedAt = now
|
||||
|
||||
const title = document.title || ''
|
||||
const targetUrl = buildExternalPlayerUrl(url, title, element)
|
||||
location.assign(targetUrl)
|
||||
}
|
||||
|
||||
function shouldInterceptCurrentPage() {
|
||||
return isAllowedHost(location.hostname)
|
||||
}
|
||||
|
||||
function rejectPlaybackRedirect() {
|
||||
return Promise.reject(new DOMException('Playback redirected to external player', 'AbortError'))
|
||||
}
|
||||
|
||||
function interceptElementPlayback(element) {
|
||||
if (!shouldInterceptCurrentPage()) return false
|
||||
|
||||
const mediaUrl = getMediaUrlFromElement(element)
|
||||
if (!isDirectMediaUrl(mediaUrl)) return false
|
||||
|
||||
try { element.pause() } catch {}
|
||||
try { element.removeAttribute('src') } catch {}
|
||||
try { element.load() } catch {}
|
||||
|
||||
openInExternalPlayer(mediaUrl, element)
|
||||
return true
|
||||
}
|
||||
|
||||
const originalPlay = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function play(...args) {
|
||||
if (interceptElementPlayback(this)) {
|
||||
return rejectPlaybackRedirect()
|
||||
}
|
||||
|
||||
return originalPlay.apply(this, args)
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!shouldInterceptCurrentPage()) return
|
||||
|
||||
const target = event.target instanceof Element ? event.target.closest('a[href]') : null
|
||||
if (!target) return
|
||||
|
||||
const href = target.getAttribute('href') || ''
|
||||
if (!isDirectMediaUrl(href)) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openInExternalPlayer(href)
|
||||
}, true)
|
||||
})()
|
||||
Reference in New Issue
Block a user