您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
An alternative Web UI to navigate Twonky servers
当前为
// ==UserScript== // @name Twonky Explorer // @version v20250626.1600 // @description An alternative Web UI to navigate Twonky servers // @author ltlwinston // @match http*://*/twonkyexplorer* // @grant GM_addElement // @grant GM_setClipboard // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js // @require https://cdn.jsdelivr.net/npm/@tailwindcss/[email protected]/dist/index.global.min.js // @namespace https://gf.qytechs.cn/users/754595 // ==/UserScript== let jQuery = unsafeWindow.jQuery || window.jQuery; const ENABLE_JSON_CACHE = true; function fixUrl(url) { if (!url || typeof url !== 'string') { return ""; } const re = /((127\.\d+\.\d+\.\d+)|(10\.\d+\.\d+\.\d+)|(172\.1[6-9]\.\d+\.\d+)|(172\.2[0-9]\.\d+\.\d+)|(172\.3[0-1]\.\d+\.\d+)|(192\.168\.\d+\.\d+))(:\d+)?/g; return url.replace(re,window.location.host); } async function loadServerStatus() { let status = await fetch('/nmc/rss/server?start=0&fmt=json').then(r => r.json()); if (status) { return status; } const serverData = {}; await fetch('/rpc/info_status').then(r => r.text()).then(s => s.split(/[\t\n ]/).forEach(i => { const [k,v] = i.split('|'); switch(k) { case 'server_udn': serverData.bookmark = v; break; default: serverData[k] = v; break; } })); return {item:[serverData]}; } function addTreeview(uuid) { const tv = jQuery('<div id="tv" style="max-width: 14em; max-height: 800px; overflow: auto;"></div>'); tv.css('float:', 'left'); tv.css('color', 'black'); tv.css('background', '#cccccc'); tv.append(createTreeLevel('/', uuid, [])); jQuery(document.body).append(tv); createNewGallery(); } function createNewGallery() { const gallery = jQuery(`<div class="newgallery" style="float: left; overflow: auto; background: white;"></div>`); jQuery(document.body).append(gallery); } function displayFolder(child) { let title = child.title; if ('childCount' in child.meta) { title = `${title} [${child.meta.childCount}]`; } const link = jQuery(` <a href="#" class="gtranslate"> ${title}<br> <span class="fa fa-folder" style="font-size: 100px"></span> </a>`); link.click((e)=>{ e.preventDefault(); e.stopPropagation(); navigateTo(child.title, child.bookmark, [], true); }); const cont = jQuery(`<p style="display: inline-block; position: relative; padding: 0.5em;"></p>`); cont.append(link); jQuery('#grid').append(cont); } function popup(title, content) { const $modal = jQuery(` <div tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-white/[var(--bg-opacity)] [--bg-opacity:70%] bg-opacity-30 place-items-center"> <div class="relative p-4 w-full max-w-5xl max-h-full"> <!-- Modal content --> <div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700"> <!-- Modal header --> <div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200"> <h3 class="text-sm font-semibold text-gray-900 dark:text-white"> ${title} </h3> <button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm h-8 w-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-toggle="crypto-modal"> <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/> </svg> <span class="sr-only">Close modal</span> </button> </div> <!-- Modal body --> <div class="p-4 md:p-5 text-sm"> <div class="bg-white p-4 text-pretty overflow-auto">${content}</div> </div> </div> </div> </div>`); jQuery('button',$modal).click(() => $modal.remove()); jQuery('#content').append($modal); setTimeout(()=>{ $modal.show(); }, 100); } function displayImage(child) { const res = child.meta.res; let src = fixUrl(res[res.length-1].value); if (res.every(x => !!x.resolution)) { src = fixUrl(res.reduce((acc, curr) => { let [cw,ch] = curr.resolution.split('x'); let [aw,ah] = acc.resolution.split('x'); return aw*ah > cw*ch ? curr : acc; }).value); } const href = fixUrl(res[0].value); const ext = child.meta['pv:extension']; delete child.meta.PersistentBookmark; const cont = jQuery(`<div class=""></div>`); /* <br> */ const link = jQuery(` <a href="${href}" target="_blank" class="grid place-items-center"> <img class="rounded-lg h-auto max-w-full hover:brightness-200 max-h-64 min-h-32" loading="lazy" loading="lazy" src="${src}"> </a>`); cont.append(link); const desc = jQuery(`<p class="text-xs text-center gtranslate"></p>`); const infoBtn = jQuery(`<a class="pr" href="#"><i class="fa fa-info-circle" style="font-size: 2em;"></i></a>`); infoBtn.click((e) => { e.preventDefault(); e.stopPropagation(); popup(child.title, Object.keys(child.meta).map(key => key + ": " + child.meta[key]).join("<br>")); }); desc.append(infoBtn); desc.append(`<b class="flex-1 ms-3 whitespace-nowrap"></b>${child.title.length > 30 ? child.title.substring(0,20)+'...' : child.title} [${ext}]`); link.append(desc); jQuery('#grid').append(cont); } function displayVideo(child) { let src = ''; let href = ''; let ext = ''; let duration = ''; const res = child.meta.res; src = fixUrl(res[res.length-1].value); if (!/(jpg|jpeg|png|gif)/i.test(src)) { src = '/resources/webbrowse/nocover_video.jpg'; } href = fixUrl(res[0].value); ext = child.meta['pv:extension']; duration = child.meta['pv:duration'] || ''; delete child.meta.PersistentBookmark; const cont = jQuery(`<div class=""></div>`); /* <br> */ const link = jQuery(` <a href="${href}" target="_blank" class="grid place-items-center"> <div class="relative grid place-items-center"> <span class="absolute bottom-1 text-sm text-shadow-[1px_1px_0px] text-shadow-white">${duration}</span> <img class="rounded-lg h-auto max-w-full hover:brightness-200 max-h-64 min-h-32" loading="lazy" loading="lazy" src="${src}"> </div> </a>`); cont.append(link); const desc = jQuery(`<p class="text-xs text-center gtranslate"></p>`); const infoBtn = jQuery(`<a class="pr" href="#"><i class="fa fa-info-circle" style="font-size: 2em;"></i></a>`); infoBtn.click((e) => { e.preventDefault(); e.stopPropagation(); popup(child.title, Object.keys(child.meta).map(key => key + ": " + child.meta[key]).join("<br>")); }); desc.append(infoBtn); desc.append(`<b class="flex-1 ms-3 whitespace-nowrap"></b>${child.title.length > 30 ? child.title.substring(0,20)+'...' : child.title} [${ext}]`); link.append(desc); jQuery('#grid').append(cont); } function fillGrid($grid, children) { children.item.forEach(child => { try{ switch (child.meta['upnp:class']) { case 'object.item.imageItem.photo': displayImage(child); break; case 'object.item.videoItem': case 'object.item.videoItem.movie': displayVideo(child); break; case 'object.container': case 'object.container.storageFolder': case 'object.container.album.photoAlbum': case 'object.container.playlistContainer': displayFolder(child); break; default: console.error('BOH!',child); } }catch(e){ console.error(e); } }); } async function openFolder(bookmark, parents, start=0, count=100) { const $content = jQuery('#content'); $content.empty(); $content.append(`<h1>LOADING <i class="fa fa-spinner fa-spin" style="font-size:1.2em"></i></h1>`); const children = await getJsonByBookmark(bookmark, start, count) .catch(e => { $content.empty(); $content.append(`<h1>Error loading data: ${e}</h1>`); }); if (!children) { return; } if (!children.item || children.item.length === 0) { if (start === 0) { $content.empty(); $content.append(`<h1>EMPTY</h1>`); } return; } $content.empty(); $content.append(`<div id="breadcrumb" class="text-left"></div>`); const $breadcrumb = jQuery('#breadcrumb'); let breadcrumbUrl = '/twonkyexplorer' parents.forEach(p => { breadcrumbUrl += `/${encodeURIElem(p.name,p.bookmark)}`; $breadcrumb.append(`<a href="${breadcrumbUrl}">${p.name}</a> » `) }); breadcrumbUrl += `/${encodeURIElem(children.title,bookmark)}`; $breadcrumb.append(`<a href="${breadcrumbUrl}">${children.title}</a>`) $content.append(`<h1 class="title mb-5 mt-5 text-2xl">${children.title} [${children.childCount}]</h1>`) const $grid = jQuery(`<div id="grid" class="grid grid-cols-4 md:grid-cols-6 gap-4"></div>`); $content.append($grid); fillGrid($grid, children); if (children.childCount>start+count) { const $btnMore = jQuery(`<button type="button" class="focus:outline-none text-white bg-yellow-400 hover:bg-yellow-500 focus:ring-4 focus:ring-yellow-300 font-medium rounded-lg text-3xl mt-10 px-5 py-2.5 me-2 mb-30 dark:focus:ring-yellow-900" id="btn-more">More...</button>`); $btnMore.data('start', start+count); $btnMore.data('count', count); $btnMore.click(async (e) => { e.preventDefault(); e.stopPropagation(); const moreStart = $btnMore.data('start'); const moreCount = $btnMore.data('count'); $btnMore.attr('disabled',true); $btnMore.html('Loading more... <i class="fa fa-spinner fa-spin"></i>'); const moreChildren = await getJsonByBookmark(bookmark, moreStart, moreCount) .catch(e => { $grid.append(`<h1>Error loading more data: ${e}</h1>`); }); if (moreChildren) { fillGrid($grid, moreChildren); } $btnMore.removeAttr('disabled'); $btnMore.html('More...'); if (children.childCount<=moreStart+moreCount) { $btnMore.hide(); }else{ $btnMore.data('start', moreStart+moreCount); $btnMore.data('count', moreCount); } }); $content.append($btnMore); $btnMore.show(); } else { jQuery('#btn-more').remove(); } } function createTreeLevel(name, bookmark, parents=[], container=false) { const levelElem = jQuery(`<li></li>`); const linkElem = jQuery(`<a href="#" class="flex items-center p-1 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"></a>`); const nameElem = jQuery(`<span class="flex-1 ms-3 whitespace-nowrap gtranslate">${name}</span>`) const loadElem = jQuery(`<i class="fa fa-spin fa-spinner"></i>`) const openElem = jQuery(`<a class="inline-flex items-center justify-center px-2 ms-3 text-sm font-medium hover:bg-green-100 text-gray-800 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-300">→</a>`); levelElem.append(linkElem); linkElem.append(nameElem); linkElem.append(loadElem); linkElem.append(openElem); loadElem.hide(); const childrenElem = jQuery(`<ul class="py-2 space-y-2 pl-5 text-sm"></ul>`); childrenElem.hide(); levelElem.append(childrenElem); openElem.click(async function (event){ event.preventDefault(); event.stopPropagation(); loadElem.show(); navigateTo(name, bookmark, parents, false); loadElem.hide(); }); linkElem.click(async function(){ event.preventDefault(); event.stopPropagation(); if(!container) { loadElem.show(); await openFolder(bookmark, [], loadElem, 0, 100); loadElem.hide(); return; } if( childrenElem.data('loaded') ) { childrenElem.toggle(); }else { loadElem.show(); getJsonByBookmark(bookmark).then(children => { if (!children.item || children.item.length === 0) { childrenElem.append(jQuery('<li>Empty</li>')); } else { children.item.forEach(child => { let title = child.title; if ('childCount' in child.meta) { title = `${title} [${child.meta.childCount}]`; } childrenElem.append(createTreeLevel(title, child.bookmark, [...parents,{name,bookmark}], /object\.container/.test(child.meta['upnp:class']))); }); childrenElem.show(); childrenElem.data('loaded', true); } loadElem.hide(); }).catch(e => { console.error('Error on getJsonByBookmark: ',e); childrenElem.empty(); childrenElem.append(`<p>ERROR: ${e}</p>`); childrenElem.show(); loadElem.hide(); }); } }); return levelElem; } function buildUrl(bookmark, start=0, count=100) { const uuid = getUuidFromBookmark(bookmark); return `/nmc/rss/server/RB${uuid},0/IB${bookmark}?start=${start}&count=${count}&fmt=json`; } function dataCachePut(id, data) { window.localStorage.setItem(id, JSON.stringify(data)); } function dataCacheGet(id) { try { return JSON.parse(window.localStorage.getItem(id)); }catch(e) { console.error(e); return null; } } function getUuidFromBookmark(bookmark) { return bookmark.split(',')[0]; } async function getJsonByBookmark(bookmark, start=0, count=100) { const cacheId = `${bookmark}_${start}_${count}`; const existing = dataCacheGet(cacheId); if (existing && ENABLE_JSON_CACHE) { return new Promise((resolve) => resolve(existing)); } console.log(`getJsonByBookmark("${bookmark}", ${start}, ${count})`); const json = await fetch(buildUrl(bookmark, start, count)+'&fmt=json').then(r => r.json()); dataCachePut(cacheId, json); return json; } unsafeWindow.changeLang = function (lang) { console.log('LANG: '+lang); } function showNoServerMessage($content) { $content.empty(); $content.append(`<h1>No compatible Twonky Server found!<br>Refresh to return to original page</h1>`); } function handleUrl(url) { const bookmarks = url.replace(/\/twonkyexplorer(\/)?/,'').split('/'); console.log('WTF???', bookmarks); if (bookmarks.length>0) { const last = bookmarks.pop(); if (last) { const {name,bookmark} = decodeURIElem(last); const parents = bookmarks.map(b => decodeURIElem(b)); console.log(`Opening Folder "${name}" @ ${bookmark} parents: `, parents); openFolder(bookmark, parents); } } } function routingSetup() { window.navigation.addEventListener('navigate', navigateEvent => { const url = new URL(navigateEvent.destination.url); if (!url.pathname.match(/twonkyexplorer/)) { return; } navigateEvent.intercept({handler: () => handleUrl(url.pathname)}); }); handleUrl(new URL(window.navigation.currentEntry.url).pathname); } function encodeURIElem(name, bookmark) { return `${encodeURIComponent(name)}_@TE@_${encodeURIComponent(bookmark)}`; } function decodeURIElem(encoded) { const [name,bookmark] = encoded.split('_@TE@_').map(x => decodeURIComponent(x)); return {name, bookmark}; } function navigateTo(name, bookmark, parents=[], append=false) { let url = '/twonkyexplorer'; parents.forEach(p => {url += '/' + encodeURIElem(p.name,p.bookmark)}); url += '/' + encodeURIElem(name, bookmark); if (append) { url = new URL(window.navigation.currentEntry.url).pathname + `/${encodeURIElem(name,bookmark)}`; } window.navigation.navigate(url); } async function initialSetup() { document.title = 'Twonky Explorer'; const $body = jQuery(document.body); const $head = jQuery(document.head); $body.empty(); $head.append(`<meta name="viewport" content="width=device-width, initial-scale=1">`); jQuery('link[type="text/css"]').remove(); GM_addElement('link',{ rel: "stylesheet", href: "//cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css" }); /* <div class="mb-5"> Translate: <select onchange="changeLang(this.value)"> <option selected>Original</option> <option value="it">ITA</option> <option value="en">ENG</option> </select> </div> */ const $layout = jQuery(` <div id="wrapper" class=""> <aside id="default-sidebar" class="fixed resize-x top-0 left-0 z-40 w-80 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar" aria-hidden="true"> <div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:text-white dark:bg-gray-800"> <h1 class="pb-5 text-red-700 text-xl"> <div class="relative inline-block text-left float-right"> <button id="btn-clean-cache" data-tooltip-target="tooltip-cache" type="button" class="text-white bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-blue-800"><i class="fa fa-eraser"></i></button> </div> Twonky Explorer<br><small>by ltlwinston</small> </h1> <ul class="space-y-2 font-medium" id="navigation"></ul> </div> </aside> <div class="sm:ml-80 resize-x p-5 text-center" id="content"> <h1>Welcome to Twonky Explorer!</h1> <h2>Use sidebar links to start your journey in this Twonky Server!</h2> </div> </div> `) $body.append($layout); const $nav = jQuery('#navigation'); const $content = jQuery('#content'); $nav.append(`<h1>LOADING <i class="fa fa-spinner fa-spin" style="font-size:1.2em"></i></h1>`); const status = await loadServerStatus(); jQuery('#btn-clean-cache').click(e => { window.localStorage.clear(); }); if (!status || !status.item || status.item.length === 0) { showNoServerMessage($content); } else { $nav.empty(); let foundOne = false; status.item.forEach(server => { if (server.title && server.bookmark) { foundOne = true; $nav.append(createTreeLevel(server.title, server.bookmark, [], true)); } }); if (!foundOne) { showNoServerMessage($content); } } routingSetup(); } (async function() { 'use strict'; initialSetup(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址