157 lines
4.6 KiB
JavaScript
157 lines
4.6 KiB
JavaScript
|
|
// ==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)
|
||
|
|
})()
|