Last active
June 15, 2025 08:29
-
-
Save Baw-Appie/be7b66a1f43db694f85834b03c5e9aca to your computer and use it in GitHub Desktop.
Revisions
-
Baw-Appie revised this gist
Jun 15, 2025 . 1 changed file with 120 additions and 260 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -4,20 +4,101 @@ // @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"); @@ -67,275 +148,54 @@ }, 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) -
Baw-Appie created this gist
Jun 15, 2025 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,355 @@ // ==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 *://open.spotify.com/* // @match *://soundcloud.com/* // @match *://music.yandex.com/* // @match *://music.yandex.ru/* // @match *://www.deezer.com/* // @match *://play.pretzel.rocks/* // @match *://*.youtube.com/* // @match *://app.plex.tv/* // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @license GPLv2 // ==/UserScript== (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) } // Safely query something, and perform operations on it function query(target, fun, alt = null) { var element = document.querySelector(target); if (element !== null) { return fun(element); } return alt; } function timestamp_to_ms(ts) { var splits = ts.split(':'); if (splits.length == 2) { return splits[0] * 60 * 1000 + splits[1] * 1000; } else if (splits.length == 3) { return splits[0] * 60 * 60 * 1000 + splits[1] * 60 * 1000 + splits[0] * 1000; } return 0; } 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; } let hostname = window.location.hostname; // TODO: maybe add more? if (hostname === 'soundcloud.com') { let status = query('.playControl', e => e.classList.contains('playing') ? "playing" : "stopped", 'unknown'); let cover = query('.playbackSoundBadge span.sc-artwork', e => e.style.backgroundImage.slice(5, -2).replace('t50x50', 't500x500')); let title = query('.playbackSoundBadge__titleLink', e => e.title); let artists = [query('.playbackSoundBadge__lightLink', e => e.title)]; let progress = query('.playbackTimeline__timePassed span:nth-child(2)', e => timestamp_to_ms(e.textContent)); let duration = query('.playbackTimeline__duration span:nth-child(2)', e => timestamp_to_ms(e.textContent)); let album_url = query('.playbackSoundBadge__titleLink', e => e.href); let album = null; // this header only exists on album/set pages so we know this is a full album album = query('.fullListenHero .soundTitle__title', e => { album_url = window.location.href; return e.innerText }) album = query('div.playlist.playing', e => { return e.getElementsByClassName('soundTitle__title')[0].innerText; }) if (title !== null) { post({ cover, title, artists, status, progress, duration, album_url, album }); } } else if (hostname === 'open.spotify.com') { if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues return; let data = navigator.mediaSession; let album = data.metadata.album; let status = query('.vnCew8qzJq3cVGlYFXRI', e => e === null ? 'stopped' : (e.getAttribute('aria-label') === 'Play' ? 'stopped' : 'playing')); let cover = data.metadata.artwork[0].src; let title = data.metadata.title let artists = [data.metadata.artist] let progress = query('.IPbBrI6yF4zhaizFmrg6', e => timestamp_to_ms(e.textContent)); let duration = query('[data-testid="playback-duration"]', e => timestamp_to_ms(e.textContent)); if (title !== null) { post({ cover, title, artists, status, progress, duration, album }); } } else if (hostname === 'music.yandex.ru') { // Yandex music support by MjKey let status = query('.player-controls__btn_play', e => e.classList.contains('player-controls__btn_pause') ? "playing" : "stopped", 'unknown'); let cover = query('.track-cover .entity-cover__image', e => e.src.replace('50x50', '200x200')); let title = query('.track__title', e => e.title); let artists = [query('.track__artists', e => e.textContent)]; let progress = query('.progress__left', e => timestamp_to_ms(e.textContent)); let duration = query('.progress__right', e => timestamp_to_ms(e.textContent)); let album_url = query('.track-cover a', e => e.title); if (title !== null) { post({ cover, title, artists, status, progress, duration, album_url }); } } else if (hostname === 'www.youtube.com') { if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues return; let artists = []; try { artists = [document.querySelector("#text > a").innerHTML.trim().replace("\n", "")]; } catch (e) { } let title = document.querySelector("#container > h1 > yt-formatted-string").innerHTML; let duration = query('video', e => e.duration * 1000); let progress = query('video', e => e.currentTime * 1000); let cover = ""; let status = query('video', e => e.paused ? 'stopped' : 'playing', 'unknown'); let regExp = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; let match = window.location.toString().match(regExp); if (match && match[2].length == 11) { cover = `https://i.ytimg.com/vi/${match[2]}/maxresdefault.jpg`; } if (title !== null) { title = title.replace(`${artists.join(", ")} - `, ""); title = title.replace(` - ${artists.join(", ")}`, ""); title = title.replace(`${artists.join(", ")}`, ""); title = title.replace("(Official Audio)", ""); title = title.replace("(Official Music Video)", ""); title = title.replace("(Original Video)", ""); title = title.replace("(Original Mix)", ""); if (status !== 'stopped') { post({ cover, title, artists, status, progress: Math.floor(progress), duration }); } else { post({ status: 'stopped', title: '', artists: [], progress: 0, duration: 0 }); } } } else if (hostname === 'music.youtube.com') { if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues return; // Youtube Music support by Rubecks const artistsSelectors = [ '.ytmusic-player-bar.byline [href*="channel/"]:not([href*="channel/MPREb_"]):not([href*="browse/MPREb_"])', // Artists with links '.ytmusic-player-bar.byline .yt-formatted-string:nth-child(2n+1):not([href*="browse/"]):not([href*="channel/"]):not(:nth-last-child(1)):not(:nth-last-child(3))', // Artists without links '.ytmusic-player-bar.byline [href*="browse/FEmusic_library_privately_owned_artist_detaila_"]', // Self uploaded music ]; const albumSelectors = [ '.ytmusic-player-bar [href*="browse/MPREb_"]', // Albums from YTM with links '.ytmusic-player-bar [href*="browse/FEmusic_library_privately_owned_release_detailb_"]', // Self uploaded music ]; let time = query('.ytmusic-player-bar.time-info', e => e.innerText.split(" / ")); let status = "unknown"; if (document.querySelector(".ytmusic-player-bar.play-pause-button path[d^='M6 19h4V5H6v14zm8-14v14h4V5h-4z']")) { status = "playing"; } status = navigator.mediaSession.playbackState; let title = query('.ytmusic-player-bar.title', e => e.title); let artists = Array.from(document.querySelectorAll(artistsSelectors)).map(x => x.innerText); let album = query(albumSelectors, e => e.textContent); let artwork = navigator.mediaSession.metadata.artwork; let cover = artwork[artwork.length - 1].src; let album_url = query(albumSelectors, e => e.href); //let progress = timestamp_to_ms(time[0]); //let duration = timestamp_to_ms(time[1]); let progress = (document.getElementsByTagName("video")[0].currentTime*1000)+100 let duration = document.getElementsByTagName("video")[0].duration*1000 if (title !== null) { post({ cover, title, artists, status, progress, duration, album_url, album }); } } else if (hostname === 'www.deezer.com') { const pauseBtn = document.querySelector('[data-testid="play_button_pause"]'); const playBtn = document.querySelector('[data-testid="play_button_play"]'); let status = pauseBtn !== null ? "playing" : (playBtn !== null ? "paused" : "stopped"); if ("mediaSession" in navigator && navigator.mediaSession.metadata !== null) { let data = navigator.mediaSession; let album = data.metadata.album; let res = data.metadata.artwork[0].sizes; let cover = data.metadata.artwork[0].src.replace(res, '512x512'); let title = data.metadata.title let artists = data.metadata.artist.split(",").map(x => x.trim()); let progress_input = document.querySelector('input.slider-track-input.mousetrap'); let progress = Math.round(progress_input.value * 1000); let duration = Math.round(progress_input.max * 1000); if (title !== null) { post({ cover, title, artists, status, progress, duration, album }); } } } else if (hostname === "play.pretzel.rocks") { // Pretzel.rocks support by Tarulia // Thanks to Rory from Pretzel for helping out :) var MSmetadata = navigator.mediaSession.metadata; let status = "unknown"; // it seems MSmetadata.playbackState isn't being populated by Pretzel, so we leave this old-school for now if (document.querySelector("[data-heapid=music-player] [data-testid=pause-button]")) { status = "playing"; } if (document.querySelector("[data-heapid=music-player] [data-testid=play-button]")) { status = "stopped"; } let cover = MSmetadata.artwork[0].src.replace('medium.jpg', 'large.jpg'); let title = MSmetadata.title; let artists = [MSmetadata.artist]; let album = MSmetadata.album; // this is not super safe against breakage, but it's the most reliable selector I can find // it's not a major deal if it breaks anyway though let album_url = query('[data-testid=track-artwork]', e => { return e.parentElement.href; }); // it seems the <audio> elements spread across the DOM are dummies just used for preloading // the actual playback is apparently done using a JavaScript/React element // as such we can access audioEl.duration because the track is preloaded let duration = query('[data-heapid=music-player] audio', e => Math.floor(e.duration) * 1000); // but we can't access audioEl.currentTime because it is not actually playing let progress = query('[data-testid=track-time-elapsed]', e => { let time = e.innerText; time = time.split(':'); // JavaScript fuckery when minutes is 0... return (Number(time[0])*60 + Number(time[1])) * 1000; }); if (title !== null) { post({ cover, title, artists, status, progress, duration, album_url, album }); } } else if (hostname === "app.plex.tv") { // simple plex web support by javaarchive // this is kind of more "universal" as it reads data from the browser media session api // see https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API for more info const mediaSessionStatesToTunaStates = { "none": "unknown", "playing": "playing", "paused": "stopped" } let status = mediaSessionStatesToTunaStates[navigator.mediaSession.playbackState] || "unknown"; if (navigator.mediaSession.metadata) { let title = navigator.mediaSession.metadata.title; let artists = [navigator.mediaSession.metadata.artist]; let mediaElem = document.getElementsByTagName("audio")[0]; // add || document.getElementsByTagName("video")[0] to support sites like yt music where video includes audio let progress = Math.floor(mediaElem.currentTime) * 1000; let duration = Math.floor(mediaElem.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 playbackState = "paused" let metadata = null let title = null Object.defineProperty(navigator.mediaSession, 'playbackState', { set(newValue) { console.log('new value for playbackState: ', newValue); playbackState = newValue; StartFunction(); }, get() { return playbackState } }); Object.defineProperty(navigator.mediaSession, 'metadata', { async set(newValue) { console.log('new value for metadata: ', newValue); await (new Promise(r => setTimeout(r, 200))) metadata = newValue; Object.defineProperty(metadata, 'title', { set(newValue) { console.log('new title: ', newValue) title = newValue; StartFunction(); }, get() { return title } }) StartFunction(); }, get() { return metadata } }); })();