Spelarrad i YT Music-stil istället för video-ruta
All checks were successful
build-and-push / build (push) Successful in 10s
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:
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
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 router = useRouter()
|
||||
@@ -38,10 +39,10 @@ async function logout() {
|
||||
<router-link class="ghost gear" :to="{ name: 'settings' }" title="Inställningar">⚙</router-link>
|
||||
<button class="ghost" @click="logout">Logga ut</button>
|
||||
</header>
|
||||
<main>
|
||||
<main :class="{ 'with-player': nowPlaying }">
|
||||
<router-view />
|
||||
</main>
|
||||
<MiniPlayer />
|
||||
<PlayerBar />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -122,4 +123,8 @@ async function logout() {
|
||||
main {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
main.with-player {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
213
frontend/src/components/PlayerBar.vue
Normal file
213
frontend/src/components/PlayerBar.vue
Normal 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>
|
||||
Reference in New Issue
Block a user