Last active
June 15, 2025 08:29
-
-
Save Baw-Appie/be7b66a1f43db694f85834b03c5e9aca to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Tuna browser script | |
// @namespace univrsal | |
// @version 1.0.28 | |
// @description Get song information from web players, based on NowSniper by Kıraç Armağan Önal | |
// @author univrsal | |
// @match *://music.youtube.com/* | |
// @grant unsafeWindow | |
// @grant GM_xmlhttpRequest | |
// @grant GM.xmlHttpRequest | |
// @license GPLv2 | |
// ==/UserScript== | |
(() => { | |
'use strict'; | |
/**************** 1. setPositionState 후킹 ****************/ | |
const origSetPos = navigator.mediaSession.setPositionState?.bind(navigator.mediaSession); | |
let basePos = 0; // 마지막 기준 위치(초) | |
let baseStamp = 0; // 기준 시각(ms, performance.now) | |
let duration = 0; // 총 길이(모를 때 0) | |
let playbackRate = 1; // 배속 | |
let isPlaying = false; // 재생 여부 | |
function updateBase(now = performance.now()) { | |
// 호출 시점을 새로운 기준으로 갱신 | |
baseStamp = now; | |
} | |
navigator.mediaSession.setPositionState = (obj = {}) => { | |
const now = performance.now(); | |
// 재생 중이라면 도착하기까지 경과한 시간만큼 위치를 보정한 뒤 새 기준으로 삼음 | |
if (isPlaying) { | |
basePos += ((now - baseStamp) / 1000) * playbackRate; | |
updateBase(now); | |
} | |
if (typeof obj.position === 'number') basePos = obj.position; | |
if (typeof obj.duration === 'number') duration = obj.duration; | |
if (typeof obj.playbackRate === 'number') playbackRate = obj.playbackRate; | |
try { origSetPos?.(obj); } catch (_) {} | |
}; | |
/**************** 2. play / pause 이벤트 감지 ****************/ | |
function handlePlayPause(playing) { | |
const now = performance.now(); | |
if (isPlaying && !playing) { | |
// └ 재생 → 일시정지로 전환될 때, 직전까지 지나간 시간을 반영 | |
basePos += ((now - baseStamp) / 1000) * playbackRate; | |
updateBase(now); | |
} else if (!isPlaying && playing) { | |
// └ 일시정지 → 재생으로 전환될 때, 기준 시각 초기화 | |
updateBase(now); | |
} | |
isPlaying = playing; | |
} | |
// 모든 audio/video 요소 추적 (currentTime 불사용) | |
const observeMedia = (el) => { | |
if (el.__msObserved) return; | |
el.__msObserved = true; | |
el.addEventListener('play', () => handlePlayPause(true), true); | |
el.addEventListener('pause', () => handlePlayPause(false), true); | |
el.addEventListener('ended', () => handlePlayPause(false), true); | |
// 초기 상태 반영 | |
handlePlayPause(!el.paused && !el.ended); | |
// 배속 변경은 position 계산에 필요하므로 따로 추적 | |
el.addEventListener('ratechange', () => { | |
playbackRate = el.playbackRate || 1; | |
}, true); | |
}; | |
const scan = () => document.querySelectorAll('audio,video').forEach(observeMedia); | |
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true }); | |
scan(); // 초기 탐색 | |
/**************** 3. 현재 위치 계산 ****************/ | |
function calcPosition() { | |
if (!baseStamp) return null; | |
if (!isPlaying) return basePos; | |
const elapsed = (performance.now() - baseStamp) / 1000; | |
let pos = basePos + elapsed * playbackRate; | |
if (duration && pos > duration) pos = duration; | |
return pos; | |
} | |
/**************** 4. 외부 API 및 주기적 이벤트 ****************/ | |
unsafeWindow.getMediaPosition = () => ({ | |
position : calcPosition(), | |
duration | |
}); | |
})(); | |
(function () { | |
'use strict'; | |
console.log("Loading tuna browser script"); | |
// Tampermonkey and violent monkey seem to have differing implementations | |
function makeRequest(data) { | |
return GM.xmlHttpRequest(data); | |
} | |
// Configuration | |
var port = 1608; | |
var refresh_rate_ms = 1000; | |
var cooldown_ms = 10000; | |
// Tuna isn't running we sleep, because every failed request will log into the console | |
// so we don't want to spam it | |
var failure_count = 0; | |
var cooldown = 0; | |
var last_state = {}; | |
function post(data) { | |
if (data.status) { | |
/* if this tab isn't playing and the status hasn't changed we don't send an update | |
* otherwise tabs that are paused would constantly send the paused/stopped state | |
* which interferes another tab that is playing something | |
*/ | |
if (data.status !== "playing" && last_state.status === data.status) { | |
return; // Prevent the paused state from being continously sent, since this tab is not playing, should prevent tabs from clashing with eachother | |
} | |
} | |
last_state = data; | |
var url = 'http://localhost:' + port + '/'; | |
var xhr = makeRequest( { | |
'method' : 'POST', | |
'url' : url, | |
data: JSON.stringify({ data, hostname: window.location.hostname, date: Date.now() }), | |
headers: { | |
'Accept': 'application/json', | |
'Content-Type': 'application/json', | |
'Access-Control-Allow-Headers': '*', | |
'Access-Control-Allow-Origin': '*' | |
}, | |
timeout: 1500 | |
}); | |
setTimeout(() => { | |
xhr.abort() | |
}, 1000) | |
} | |
function StartFunction() { | |
if (failure_count > 3) { | |
console.log('Failed to connect multiple times, waiting a few seconds'); | |
cooldown = cooldown_ms; | |
failure_count = 0; | |
} | |
if (cooldown > 0) { | |
cooldown -= refresh_rate_ms; | |
return; | |
} | |
const mediaSessionStatesToTunaStates = { | |
"none": "unknown", | |
"playing": "playing", | |
"paused": "stopped" | |
} | |
let status = mediaSessionStatesToTunaStates[navigator.mediaSession.playbackState] || navigator.mediaSession.playbackState; | |
if (navigator.mediaSession.metadata) { | |
let title = navigator.mediaSession.metadata.title; | |
let artists = [navigator.mediaSession.metadata.artist]; | |
let mediaElem = document.querySelectorAll('audio,video')[0]; | |
let progress = unsafeWindow.getMediaPosition().position * 1000; | |
let duration = unsafeWindow.getMediaPosition().duration * 1000; | |
let artworks = navigator.mediaSession.metadata.artwork; | |
let album = navigator.mediaSession.metadata.album; | |
let album_url = artworks[artworks.length - 1].src; | |
let cover = album_url; // For now. | |
if (title !== null) { | |
post({ cover, title, artists, status, progress, duration, album, album_url }); | |
} | |
} | |
} | |
setInterval(() => { | |
StartFunction(); | |
}, refresh_rate_ms); | |
let metadata = null | |
let title = null | |
Object.defineProperty(navigator.mediaSession, 'metadata', { | |
async set(newValue) { | |
console.log('new value for metadata: ', newValue); | |
await (new Promise(r => setTimeout(r, 200))) | |
metadata = newValue; | |
title = newValue.title; | |
Object.defineProperty(metadata, 'title', { | |
set(newValue) { | |
console.log('new title: ', newValue) | |
title = newValue; | |
StartFunction(); | |
}, | |
get() { | |
return title | |
} | |
}) | |
StartFunction(); | |
}, | |
get() { | |
return metadata | |
} | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment