first commit
This commit is contained in:
12
public/manifest.json
Normal file
12
public/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "API Media Player",
|
||||
"short_name": "MediaPlayer",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFFFFF",
|
||||
"theme_color": "#6200EE",
|
||||
"icons": [
|
||||
{ "src": "no-image.svg", "sizes": "192x192", "type": "image/svg+xml" },
|
||||
{ "src": "no-image.svg", "sizes": "512x512", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
4
public/no-image.svg
Normal file
4
public/no-image.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="140">
|
||||
<rect fill="#eee" width="100%" height="100%"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="Arial, sans-serif" font-size="20">No Image</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
169
public/sw.js
Normal file
169
public/sw.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const CACHE_NAME = 'api-mediaplayer-v1'
|
||||
const MEDIA_CACHE_NAME = 'api-mediaplayer-media-v2'
|
||||
const ASSETS = ['/', '/index.html', '/manifest.json', '/no-image.svg']
|
||||
const MAX_MEDIA_CACHE_ITEMS = 12
|
||||
|
||||
function getRangeBounds(rangeHeader, size) {
|
||||
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader || '')
|
||||
if (!match) return null
|
||||
|
||||
let start = match[1] ? Number(match[1]) : NaN
|
||||
let end = match[2] ? Number(match[2]) : NaN
|
||||
|
||||
if (Number.isNaN(start) && Number.isNaN(end)) return null
|
||||
if (Number.isNaN(start)) {
|
||||
const suffixLength = end
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0) return null
|
||||
start = Math.max(size - suffixLength, 0)
|
||||
end = size - 1
|
||||
} else {
|
||||
if (!Number.isFinite(start) || start < 0 || start >= size) return null
|
||||
if (Number.isNaN(end) || end >= size) end = size - 1
|
||||
}
|
||||
|
||||
if (end < start) return null
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
async function trimMediaCache(cache) {
|
||||
const keys = await cache.keys()
|
||||
if (keys.length <= MAX_MEDIA_CACHE_ITEMS) return
|
||||
|
||||
const overflow = keys.length - MAX_MEDIA_CACHE_ITEMS
|
||||
for (let index = 0; index < overflow; index += 1) {
|
||||
await cache.delete(keys[index])
|
||||
}
|
||||
}
|
||||
|
||||
async function createRangeResponse(cachedResponse, rangeHeader) {
|
||||
const blob = await cachedResponse.blob()
|
||||
const size = blob.size
|
||||
const bounds = getRangeBounds(rangeHeader, size)
|
||||
|
||||
if (!bounds) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { 'Content-Range': `bytes */${size}` }
|
||||
})
|
||||
}
|
||||
|
||||
const { start, end } = bounds
|
||||
const slice = blob.slice(start, end + 1)
|
||||
const headers = new Headers(cachedResponse.headers)
|
||||
headers.set('Accept-Ranges', 'bytes')
|
||||
headers.set('Content-Length', String(end - start + 1))
|
||||
headers.set('Content-Range', `bytes ${start}-${end}/${size}`)
|
||||
|
||||
return new Response(slice, {
|
||||
status: 206,
|
||||
statusText: 'Partial Content',
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMediaRequest(event) {
|
||||
const mediaCache = await caches.open(MEDIA_CACHE_NAME)
|
||||
const cacheKey = event.request.url
|
||||
const rangeHeader = event.request.headers && event.request.headers.get && event.request.headers.get('range')
|
||||
const cached = await mediaCache.match(cacheKey).catch(() => null)
|
||||
|
||||
if (cached) {
|
||||
if (rangeHeader) return createRangeResponse(cached, rangeHeader)
|
||||
return cached
|
||||
}
|
||||
|
||||
const networkResponse = await fetch(event.request)
|
||||
|
||||
if (!rangeHeader && networkResponse.ok && networkResponse.status === 200) {
|
||||
event.waitUntil(
|
||||
mediaCache.put(cacheKey, networkResponse.clone()).then(() => trimMediaCache(mediaCache)).catch(() => undefined)
|
||||
)
|
||||
}
|
||||
|
||||
return networkResponse
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(ASSETS)
|
||||
}).then(() => self.skipWaiting())
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.map((k) => (k !== CACHE_NAME && k !== MEDIA_CACHE_NAME ? caches.delete(k) : Promise.resolve())))).then(() => self.clients.claim())
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return
|
||||
|
||||
const url = event.request.url || ''
|
||||
const acceptHeader = (event.request.headers && event.request.headers.get && event.request.headers.get('accept')) || ''
|
||||
const destination = event.request.destination || ''
|
||||
const hasRangeHeader = event.request.headers && event.request.headers.get && event.request.headers.get('range')
|
||||
const isNativeVideoBypass = /(?:\?|&)_native_video=1(?:&|$)/i.test(url)
|
||||
const isVideoRequest = destination === 'video' || /video\//i.test(acceptHeader)
|
||||
const isAudioRequest = destination === 'audio' || /audio\//i.test(acceptHeader)
|
||||
const isMediaRequest = isVideoRequest
|
||||
|| isAudioRequest
|
||||
|| /\/get_files\/(file|thumbnail)\?/i.test(url)
|
||||
|| /\.(m3u8|mp4|webm|ogg|mov)(\?|$)/i.test(url)
|
||||
|
||||
if (isNativeVideoBypass || isVideoRequest) {
|
||||
event.respondWith(fetch(event.request))
|
||||
return
|
||||
}
|
||||
|
||||
if (isMediaRequest || hasRangeHeader) {
|
||||
event.respondWith(handleMediaRequest(event))
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const cached = await caches.match(event.request).catch(() => null)
|
||||
if (cached) return cached
|
||||
|
||||
try {
|
||||
const response = await fetch(event.request)
|
||||
return response
|
||||
} catch (networkErr) {
|
||||
// On network failure, provide a graceful fallback for images and navigation
|
||||
try {
|
||||
const dest = event.request.destination || ''
|
||||
const url = event.request.url || ''
|
||||
|
||||
// Image fallback
|
||||
if (dest === 'image' || /\.(png|jpe?g|gif|svg|webp)(\?|$)/i.test(url)) {
|
||||
const fallback = await caches.match('/no-image.svg').catch(() => null)
|
||||
if (fallback) return fallback
|
||||
return new Response("<svg xmlns='http://www.w3.org/2000/svg' width='300' height='140'><rect fill='%23eee' width='100%' height='100%'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='Arial, sans-serif' font-size='20'>No Image</text></svg>", { headers: { 'Content-Type': 'image/svg+xml' } })
|
||||
}
|
||||
|
||||
// Navigation fallback (HTML)
|
||||
if (event.request.mode === 'navigate' || (event.request.headers && event.request.headers.get && event.request.headers.get('accept') && event.request.headers.get('accept').includes('text/html'))) {
|
||||
const index = await caches.match('/index.html').catch(() => null)
|
||||
if (index) return index
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SW network error fallback failed', e)
|
||||
}
|
||||
|
||||
return new Response('Network error', { status: 502, statusText: 'Bad Gateway' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SW fetch handler error', e)
|
||||
return new Response('SW internal error', { status: 500 })
|
||||
}
|
||||
})())
|
||||
})
|
||||
|
||||
// Support skip waiting via message
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event && event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
})
|
||||
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