First commit
This commit is contained in:
840
index.html
Normal file
840
index.html
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sv">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>MTVify</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #000;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==== NOW PLAYING OVERLAY — flyttad till övre vänstra hörnet ==== */
|
||||||
|
|
||||||
|
#overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; /* ÄNDRAT: tidigare bottom:0 */
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
/* Ny overlay-positionering för top-left */
|
||||||
|
padding: 14px 30px;
|
||||||
|
margin: 20px 0 0 20px; /* ÄNDRAT: tidigare margin-bottom */
|
||||||
|
|
||||||
|
background: linear-gradient(90deg, #ff2fb0, #ffd300);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.7);
|
||||||
|
box-shadow: 0 0 28px rgba(255, 0, 160, 0.65);
|
||||||
|
|
||||||
|
transform: translateY(-160%); /* ÄNDRAT: tidigare +140% */
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.45s ease-out, opacity 0.45s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overlay.visible .overlay-inner {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
animation: neonPulse 1.7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-logo {
|
||||||
|
height: 56px;
|
||||||
|
width: auto;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(0,0,0,0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #1b0020;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-title {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 6px rgba(0,0,0,0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 70vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neonPulse {
|
||||||
|
0% { box-shadow: 0 0 14px rgba(255, 0, 160, 0.6); }
|
||||||
|
50% { box-shadow: 0 0 26px rgba(255, 255, 255, 0.95); }
|
||||||
|
100% { box-shadow: 0 0 14px rgba(255, 0, 160, 0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==== PANEL / KONTROLLER ==== */
|
||||||
|
|
||||||
|
#panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
padding: 10px;
|
||||||
|
width: 340px;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePanel {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePanel.hiddenAuto {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel button {
|
||||||
|
padding: 6px;
|
||||||
|
margin: 2px;
|
||||||
|
background: #444;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #666;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel h3 {
|
||||||
|
margin: 10px 0 6px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row label {
|
||||||
|
width: 20px;
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row input[type=range] {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent {
|
||||||
|
width: 35px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
font-size: 14px;
|
||||||
|
background: #111;
|
||||||
|
padding: 5px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song label {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-actions {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-actions button {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#importFile { display: none; }
|
||||||
|
|
||||||
|
/* ==== IDENT / BUMPER ==== */
|
||||||
|
|
||||||
|
#identOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle at top left, #ff2fb0 0%, #2b0b60 35%, #050015 80%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.4s ease-out;
|
||||||
|
z-index: 800;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#identOverlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ident-inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px 36px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
box-shadow: 0 0 40px rgba(0, 0, 0, 0.9);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ident-inner::before,
|
||||||
|
.ident-inner::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 160%;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #00e5ff, #ff2fb0, #ffd300);
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
left: -30%;
|
||||||
|
animation: identStripes 2.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ident-inner::after {
|
||||||
|
top: auto;
|
||||||
|
bottom: 18%;
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes identStripes {
|
||||||
|
0% { transform: translateX(0) rotate(-18deg); }
|
||||||
|
100% { transform: translateX(40%) rotate(-18deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ident-logo {
|
||||||
|
height: 100px;
|
||||||
|
width: auto;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(0,0,0,0.9));
|
||||||
|
animation: identLogo 1.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes identLogo {
|
||||||
|
0% { transform: scale(0.3) rotate(-10deg); opacity: 0; }
|
||||||
|
50% { transform: scale(1.1) rotate(3deg); opacity: 1; }
|
||||||
|
100% { transform: scale(1.0) rotate(0deg); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ident-text {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 8px rgba(0,0,0,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ident-tagline {
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: #ffdfff;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="togglePanel" onclick="togglePanel()">Visa / Dölj panel</div>
|
||||||
|
|
||||||
|
<div id="player"></div>
|
||||||
|
|
||||||
|
<!-- NOW PLAYING overlay (nu top-left) -->
|
||||||
|
<div id="overlay">
|
||||||
|
<div class="overlay-inner">
|
||||||
|
<img src="mtvify.png" alt="MTVify" class="overlay-logo">
|
||||||
|
<div class="overlay-text">
|
||||||
|
<div class="overlay-label">NOW PLAYING</div>
|
||||||
|
<div class="overlay-title" id="overlayTitle">Artist – Titel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IDENT -->
|
||||||
|
<div id="identOverlay">
|
||||||
|
<div class="ident-inner">
|
||||||
|
<img src="mtvify.png" alt="MTVify" class="ident-logo">
|
||||||
|
<div class="ident-text">MTVIFY</div>
|
||||||
|
<div class="ident-tagline">non-stop music videos</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PANEL (oförändrat) -->
|
||||||
|
<div id="panel" class="hidden">
|
||||||
|
<h3>Lägg till låt</h3>
|
||||||
|
|
||||||
|
<input id="urlInput" type="text" placeholder="YouTube-länk eller ID">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button onclick="addSongTo('A')">Lägg i A</button>
|
||||||
|
<button onclick="addSongTo('B')">Lägg i B</button>
|
||||||
|
<button onclick="addSongTo('C')">Lägg i C</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Viktning</h3>
|
||||||
|
|
||||||
|
<div class="slider-row">
|
||||||
|
<label>A:</label>
|
||||||
|
<input type="range" id="weightA" min="0" max="100" value="60" oninput="updateWeights()">
|
||||||
|
<div class="percent" id="weightAVal">60%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-row">
|
||||||
|
<label>B:</label>
|
||||||
|
<input type="range" id="weightB" min="0" max="100" value="30" oninput="updateWeights()">
|
||||||
|
<div class="percent" id="weightBVal">30%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-row">
|
||||||
|
<label>C:</label>
|
||||||
|
<input type="range" id="weightC" min="0" max="100" value="10" oninput="updateWeights()">
|
||||||
|
<div class="percent" id="weightCVal">10%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="exportPlaylists()">Exportera</button>
|
||||||
|
<button onclick="document.getElementById('importFile').click()">Importera</button>
|
||||||
|
<button onclick="clearAll()">Rensa allt</button>
|
||||||
|
<input type="file" id="importFile" accept="application/json" onchange="importPlaylists(event)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Lista A</h3>
|
||||||
|
<div class="list" id="listA"></div>
|
||||||
|
|
||||||
|
<h3>Lista B</h3>
|
||||||
|
<div class="list" id="listB"></div>
|
||||||
|
|
||||||
|
<h3>Lista C</h3>
|
||||||
|
<div class="list" id="listC"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://www.youtube.com/iframe_api"></script>
|
||||||
|
<script>
|
||||||
|
/* ============================
|
||||||
|
DATA & STORAGE
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
let lists = { A: [], B: [], C: [] };
|
||||||
|
let weights = { A: 0.6, B: 0.3, C: 0.1 };
|
||||||
|
|
||||||
|
if (localStorage.getItem("mtvify")) {
|
||||||
|
try { lists = JSON.parse(localStorage.getItem("mtvify")); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (localStorage.getItem("mtvify-weights")) {
|
||||||
|
try {
|
||||||
|
weights = JSON.parse(localStorage.getItem("mtvify-weights"));
|
||||||
|
const wA = Math.round(weights.A * 100);
|
||||||
|
const wB = Math.round(weights.B * 100);
|
||||||
|
const wC = Math.round(weights.C * 100);
|
||||||
|
document.getElementById("weightA").value = wA;
|
||||||
|
document.getElementById("weightB").value = wB;
|
||||||
|
document.getElementById("weightC").value = wC;
|
||||||
|
document.getElementById("weightAVal").innerText = wA + "%";
|
||||||
|
document.getElementById("weightBVal").innerText = wB + "%";
|
||||||
|
document.getElementById("weightCVal").innerText = wC + "%";
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
localStorage.setItem("mtvify", JSON.stringify(lists));
|
||||||
|
localStorage.setItem("mtvify-weights", JSON.stringify(weights));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
AUTO-HIDE TOGGLE BUTTON
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
let hideTimer = null;
|
||||||
|
|
||||||
|
function resetHideTimer() {
|
||||||
|
const btn = document.getElementById("togglePanel");
|
||||||
|
btn.classList.remove("hiddenAuto");
|
||||||
|
|
||||||
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
btn.classList.add("hiddenAuto");
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetHideTimer();
|
||||||
|
document.addEventListener("mousemove", resetHideTimer);
|
||||||
|
document.addEventListener("mousedown", resetHideTimer);
|
||||||
|
document.addEventListener("keydown", resetHideTimer);
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
PANEL
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
document.getElementById("panel").classList.toggle("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
UI RENDER LISTS
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
["A","B","C"].forEach(L => {
|
||||||
|
const el = document.getElementById("list" + L);
|
||||||
|
el.innerHTML = lists[L].map(song => `
|
||||||
|
<div class="song">
|
||||||
|
<div class="song-title">${song.title}</div>
|
||||||
|
<div>
|
||||||
|
<label><input type="radio" name="${song.id}" ${L === "A" ? "checked" : ""} onclick="moveSong('${song.id}','A')"> A</label>
|
||||||
|
<label><input type="radio" name="${song.id}" ${L === "B" ? "checked" : ""} onclick="moveSong('${song.id}','B')"> B</label>
|
||||||
|
<label><input type="radio" name="${song.id}" ${L === "C" ? "checked" : ""} onclick="moveSong('${song.id}','C')"> C</label>
|
||||||
|
</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<button onclick="editSongTitle('${song.id}')">Ändra titel</button>
|
||||||
|
<button onclick="deleteSong('${song.id}')">Ta bort</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
WEIGHTS (SLIDERS)
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function updateWeights() {
|
||||||
|
let a = parseFloat(document.getElementById("weightA").value);
|
||||||
|
let b = parseFloat(document.getElementById("weightB").value);
|
||||||
|
let c = parseFloat(document.getElementById("weightC").value);
|
||||||
|
|
||||||
|
let total = a + b + c;
|
||||||
|
if (total === 0) total = 1;
|
||||||
|
|
||||||
|
weights.A = a / total;
|
||||||
|
weights.B = b / total;
|
||||||
|
weights.C = c / total;
|
||||||
|
|
||||||
|
document.getElementById("weightAVal").innerText = Math.round(weights.A * 100) + "%";
|
||||||
|
document.getElementById("weightBVal").innerText = Math.round(weights.B * 100) + "%";
|
||||||
|
document.getElementById("weightCVal").innerText = Math.round(weights.C * 100) + "%";
|
||||||
|
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
LIST HANDLING
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function moveSong(id, targetList) {
|
||||||
|
for (let L of ["A","B","C"]) {
|
||||||
|
const idx = lists[L].findIndex(s => s.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const song = lists[L][idx];
|
||||||
|
lists[L].splice(idx, 1);
|
||||||
|
if (!lists[targetList].some(s => s.id === id)) {
|
||||||
|
lists[targetList].push(song);
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSongTitle(id) {
|
||||||
|
for (let L of ["A","B","C"]) {
|
||||||
|
const s = lists[L].find(song => song.id === id);
|
||||||
|
if (s) {
|
||||||
|
const newTitle = prompt("Ny titel:", s.title);
|
||||||
|
if (newTitle && newTitle.trim() !== "") {
|
||||||
|
s.title = newTitle.trim();
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSong(id) {
|
||||||
|
if (!confirm("Ta bort denna video från alla listor?")) return;
|
||||||
|
["A","B","C"].forEach(L => {
|
||||||
|
lists[L] = lists[L].filter(song => song.id !== id);
|
||||||
|
});
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
ADD SONG + TITLE FETCH
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function extractID(url) {
|
||||||
|
if (url.includes("youtube.com")) {
|
||||||
|
try { return new URL(url).searchParams.get("v"); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (url.includes("youtu.be"))
|
||||||
|
return url.split("/").pop().split("?")[0];
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSongTo(listName) {
|
||||||
|
const input = document.getElementById("urlInput");
|
||||||
|
const url = input.value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const id = extractID(url);
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (lists[listName].some(s => s.id === id)) {
|
||||||
|
input.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lists[listName].push({ id, title: "Laddar titel…" });
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
input.value = "";
|
||||||
|
|
||||||
|
fetchRealTitle(id, real => {
|
||||||
|
["A","B","C"].forEach(L => {
|
||||||
|
const s = lists[L].find(x => x.id === id);
|
||||||
|
if (s) s.title = real;
|
||||||
|
});
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchRealTitle(id, callback) {
|
||||||
|
if (typeof YT === "undefined" || !YT.Player) {
|
||||||
|
callback(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const miniDiv = document.createElement("div");
|
||||||
|
miniDiv.style.width = "1px";
|
||||||
|
miniDiv.style.height = "1px";
|
||||||
|
miniDiv.style.overflow = "hidden";
|
||||||
|
miniDiv.style.position = "absolute";
|
||||||
|
miniDiv.style.bottom = "0";
|
||||||
|
miniDiv.style.left = "0";
|
||||||
|
miniDiv.style.zIndex = "1";
|
||||||
|
document.body.appendChild(miniDiv);
|
||||||
|
|
||||||
|
const mini = new YT.Player(miniDiv, {
|
||||||
|
height: "1",
|
||||||
|
width: "1",
|
||||||
|
videoId: id,
|
||||||
|
playerVars: { controls: 0, autoplay: 0, iv_load_policy: 3 },
|
||||||
|
events: {
|
||||||
|
onReady: function() {
|
||||||
|
setTimeout(() => {
|
||||||
|
let title = id;
|
||||||
|
try {
|
||||||
|
const data = mini.getVideoData();
|
||||||
|
if (data && data.title) title = data.title;
|
||||||
|
} catch(e) {}
|
||||||
|
callback(title);
|
||||||
|
mini.destroy();
|
||||||
|
miniDiv.remove();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
IMPORT / EXPORT / CLEAR
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function exportPlaylists() {
|
||||||
|
const data = JSON.stringify(lists, null, 2);
|
||||||
|
const blob = new Blob([data], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "mtvify-playlists.json";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importPlaylists(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(e.target.result);
|
||||||
|
|
||||||
|
["A","B","C"].forEach(L => {
|
||||||
|
if (Array.isArray(imported[L])) {
|
||||||
|
imported[L].forEach(song => {
|
||||||
|
if (!song || !song.id) return;
|
||||||
|
if (!lists[L].some(s => s.id === song.id)) {
|
||||||
|
lists[L].push({
|
||||||
|
id: song.id,
|
||||||
|
title: song.title || song.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fel vid import", err);
|
||||||
|
} finally {
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
if (!confirm("Rensa alla listor permanent?")) return;
|
||||||
|
lists = { A: [], B: [], C: [] };
|
||||||
|
save();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
PLAYBACK HISTORY
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
let playHistory = [];
|
||||||
|
const historyLimit = 3;
|
||||||
|
|
||||||
|
function registerPlayed(id) {
|
||||||
|
playHistory.push(id);
|
||||||
|
if (playHistory.length > historyLimit) {
|
||||||
|
playHistory.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
PICK NEXT
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function pickNext() {
|
||||||
|
for (let attempt = 0; attempt < 30; attempt++) {
|
||||||
|
const r = Math.random();
|
||||||
|
const a = weights.A;
|
||||||
|
const b = weights.B;
|
||||||
|
|
||||||
|
let pick;
|
||||||
|
if (r < a) pick = pickFrom("A");
|
||||||
|
else if (r < a + b) pick = pickFrom("B");
|
||||||
|
else pick = pickFrom("C");
|
||||||
|
|
||||||
|
if (!pick) continue;
|
||||||
|
|
||||||
|
if (!playHistory.includes(pick.id))
|
||||||
|
return pick;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickFrom("A") || pickFrom("B") || pickFrom("C");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFrom(L) {
|
||||||
|
if (lists[L].length === 0) return null;
|
||||||
|
return lists[L][Math.floor(Math.random() * lists[L].length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
PLAYER + IDENT + END-SKIP
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
let player;
|
||||||
|
let endWatchInterval = null;
|
||||||
|
let earlyHandled = false;
|
||||||
|
|
||||||
|
function showIdent() {
|
||||||
|
const el = document.getElementById("identOverlay");
|
||||||
|
el.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideIdent() {
|
||||||
|
const el = document.getElementById("identOverlay");
|
||||||
|
el.classList.remove("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTrack(track) {
|
||||||
|
if (!player || !track) return;
|
||||||
|
player.loadVideoById(track.id);
|
||||||
|
player.playVideo();
|
||||||
|
showOverlay(track.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showIdentAndMaybePlay(track, isFirst, forceIdent) {
|
||||||
|
const shouldShow = isFirst || forceIdent;
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
|
showIdent();
|
||||||
|
setTimeout(() => {
|
||||||
|
startTrack(track);
|
||||||
|
hideIdent();
|
||||||
|
}, 2500);
|
||||||
|
} else {
|
||||||
|
startTrack(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSongFinished(isFirst = false) {
|
||||||
|
const next = pickNext();
|
||||||
|
if (!next) return;
|
||||||
|
registerPlayed(next.id);
|
||||||
|
|
||||||
|
const forceIdent = Math.random() < 0.05;
|
||||||
|
showIdentAndMaybePlay(next, false, forceIdent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onYouTubeIframeAPIReady() {
|
||||||
|
const first = pickNext() || { id: "dQw4w9WgXcQ", title: "Fallback" };
|
||||||
|
registerPlayed(first.id);
|
||||||
|
|
||||||
|
player = new YT.Player("player", {
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
videoId: first.id,
|
||||||
|
playerVars: {
|
||||||
|
autoplay: 0,
|
||||||
|
controls: 0,
|
||||||
|
modestbranding: 1,
|
||||||
|
rel: 0,
|
||||||
|
fs: 0,
|
||||||
|
iv_load_policy: 3,
|
||||||
|
disablekb: 1
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
showIdentAndMaybePlay(first, true, true);
|
||||||
|
},
|
||||||
|
onStateChange: onPlayerStateChange
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlayerStateChange(e) {
|
||||||
|
if (e.data === YT.PlayerState.PLAYING) {
|
||||||
|
earlyHandled = false;
|
||||||
|
if (endWatchInterval) clearInterval(endWatchInterval);
|
||||||
|
|
||||||
|
endWatchInterval = setInterval(() => {
|
||||||
|
if (!player || typeof player.getDuration !== "function") return;
|
||||||
|
const dur = player.getDuration();
|
||||||
|
const ct = player.getCurrentTime();
|
||||||
|
if (dur > 0 && dur - ct <= 4 && !earlyHandled) {
|
||||||
|
earlyHandled = true;
|
||||||
|
clearInterval(endWatchInterval);
|
||||||
|
endWatchInterval = null;
|
||||||
|
handleSongFinished(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} else if (e.data === YT.PlayerState.ENDED) {
|
||||||
|
if (endWatchInterval) {
|
||||||
|
clearInterval(endWatchInterval);
|
||||||
|
endWatchInterval = null;
|
||||||
|
}
|
||||||
|
if (!earlyHandled) {
|
||||||
|
handleSongFinished(false);
|
||||||
|
}
|
||||||
|
} else if (e.data === YT.PlayerState.PAUSED || e.data === YT.PlayerState.BUFFERING) {
|
||||||
|
if (endWatchInterval) {
|
||||||
|
clearInterval(endWatchInterval);
|
||||||
|
endWatchInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
NOW PLAYING OVERLAY
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
function showOverlay(title) {
|
||||||
|
const overlay = document.getElementById("overlay");
|
||||||
|
const titleEl = document.getElementById("overlayTitle");
|
||||||
|
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.classList.add("visible");
|
||||||
|
|
||||||
|
clearTimeout(showOverlay._timer);
|
||||||
|
showOverlay._timer = setTimeout(() => {
|
||||||
|
overlay.classList.remove("visible");
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
mtvify.png
Normal file
BIN
mtvify.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Reference in New Issue
Block a user