Spelarrad i YT Music-stil istället för video-ruta
All checks were successful
build-and-push / build (push) Successful in 10s

Full bredd längst ner: play/paus, förfluten tid/längd, sökbar progress,
låttitel och volym. Videon göms off-screen och styrs via YouTube
IFrame-API:t (loadVideoById/seekTo/setVolume); raden ligger kvar
mellan låtar och stängs med ✕. Innehållet får bottenmarginal när
spelaren är aktiv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
2026-08-01 05:48:30 +02:00
parent 07264be338
commit cbdd1f1296
3 changed files with 221 additions and 67 deletions

View File

@@ -1,7 +1,8 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import MiniPlayer from './components/MiniPlayer.vue' import PlayerBar from './components/PlayerBar.vue'
import { nowPlaying } from './player.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -38,10 +39,10 @@ async function logout() {
<router-link class="ghost gear" :to="{ name: 'settings' }" title="Inställningar"></router-link> <router-link class="ghost gear" :to="{ name: 'settings' }" title="Inställningar"></router-link>
<button class="ghost" @click="logout">Logga ut</button> <button class="ghost" @click="logout">Logga ut</button>
</header> </header>
<main> <main :class="{ 'with-player': nowPlaying }">
<router-view /> <router-view />
</main> </main>
<MiniPlayer /> <PlayerBar />
</template> </template>
<style scoped> <style scoped>
@@ -122,4 +123,8 @@ async function logout() {
main { main {
padding: 1.5rem; padding: 1.5rem;
} }
main.with-player {
padding-bottom: 5rem;
}
</style> </style>

View File

@@ -1,64 +0,0 @@
<script setup>
import { nowPlaying } from '../player.js'
</script>
<template>
<div v-if="nowPlaying" class="player">
<div class="bar">
<span class="title" :title="nowPlaying.title"> {{ nowPlaying.title }}</span>
<button class="close" @click="nowPlaying = null"></button>
</div>
<iframe
:src="`https://www.youtube.com/embed/${nowPlaying.videoId}?autoplay=1`"
title="Spelare"
frameborder="0"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
></iframe>
</div>
</template>
<style scoped>
.player {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 100;
width: min(24rem, calc(100vw - 2rem));
background: var(--bg-deep);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.8rem;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.4rem 0.4rem 0.4rem 0.8rem;
}
.title {
font-size: 0.85rem;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close {
background: transparent;
color: var(--text-dim);
padding: 0.15rem 0.5rem;
flex: 0 0 auto;
}
iframe {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup>
import { onBeforeUnmount, ref, watch } from 'vue'
import { nowPlaying } from '../player.js'
const playing = ref(false)
const current = ref(0)
const duration = ref(0)
const volume = ref(80)
let player = null
let timer = null
let apiPromise = null
// YouTube IFrame-API:t laddas en gång; själva videon göms off-screen och
// styrs helt via spelarraden (playVideo/pauseVideo/seekTo/setVolume).
function loadApi() {
if (window.YT?.Player) return Promise.resolve()
if (!apiPromise) {
apiPromise = new Promise((resolve) => {
const prev = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
prev?.()
resolve()
}
const s = document.createElement('script')
s.src = 'https://www.youtube.com/iframe_api'
document.head.appendChild(s)
})
}
return apiPromise
}
async function start(video) {
await loadApi()
if (player) {
player.loadVideoById(video.videoId)
return
}
player = new window.YT.Player('yt-holder', {
width: '200',
height: '112',
videoId: video.videoId,
playerVars: { autoplay: 1, controls: 0, disablekb: 1 },
events: {
onReady: (e) => {
e.target.setVolume(volume.value)
e.target.playVideo()
},
onStateChange: (e) => {
playing.value = e.data === 1
if (e.data === 0) nowPlaying.value = null // låten är slut
},
},
})
if (!timer) {
timer = setInterval(() => {
if (!player?.getCurrentTime) return
current.value = player.getCurrentTime() ?? 0
duration.value = player.getDuration() ?? 0
}, 500)
}
}
watch(nowPlaying, (v) => {
if (v) {
current.value = 0
duration.value = 0
start(v)
} else if (player) {
player.stopVideo()
playing.value = false
}
})
function toggle() {
if (!player) return
if (playing.value) player.pauseVideo()
else player.playVideo()
}
function seek(e) {
player?.seekTo(Number(e.target.value), true)
current.value = Number(e.target.value)
}
function setVol() {
player?.setVolume(volume.value)
}
function fmt(s) {
s = Math.max(0, Math.floor(s || 0))
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div v-show="nowPlaying" class="bar">
<button class="ctl play" :title="playing ? 'Pausa' : 'Spela'" @click="toggle">
{{ playing ? '' : '' }}
</button>
<span class="time">{{ fmt(current) }} / {{ fmt(duration) }}</span>
<input
class="progress"
type="range"
min="0"
:max="Math.floor(duration) || 0"
step="1"
:value="Math.floor(current)"
aria-label="Position i låten"
@input="seek"
/>
<span class="title" :title="nowPlaying?.title">{{ nowPlaying?.title }}</span>
<span class="vol">
🔊
<input v-model.number="volume" type="range" min="0" max="100" aria-label="Volym" @input="setVol" />
</span>
<button class="ctl close" title="Stäng spelaren" @click="nowPlaying = null"></button>
<div id="yt-holder" class="holder"></div>
</div>
</template>
<style scoped>
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
display: flex;
align-items: center;
gap: 0.9rem;
padding: 0.55rem 1.1rem;
background: rgba(11, 17, 32, 0.97);
border-top: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(8px);
}
.ctl {
background: transparent;
color: var(--text);
font-size: 1.15rem;
padding: 0.25rem 0.55rem;
flex: 0 0 auto;
}
.ctl:hover {
background: rgba(255, 255, 255, 0.08);
}
.play {
font-size: 1.4rem;
}
.close {
color: var(--text-dim);
}
.time {
font-size: 0.82rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex: 0 0 auto;
}
.progress {
flex: 1;
min-width: 4rem;
accent-color: var(--accent);
}
.title {
flex: 0 1 auto;
max-width: 28%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
}
.vol {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 0 0 auto;
}
.vol input {
width: 6rem;
accent-color: var(--accent);
}
.holder {
position: absolute;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 45rem) {
.title {
display: none;
}
.vol input {
width: 4rem;
}
}
</style>