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>
|
<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>
|
||||||
|
|||||||
@@ -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