Added fullscreen mode to miniplayer
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { queue } from '$lib/stores'
|
||||
|
||||
let queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
|
||||
|
||||
let image: HTMLImageElement, captionText: HTMLDivElement
|
||||
</script>
|
||||
|
||||
@@ -28,7 +30,7 @@
|
||||
<IconButton
|
||||
halo={true}
|
||||
on:click={() => {
|
||||
if (mediaItem.type === 'song') $queue.enqueue(mediaItem)
|
||||
if (mediaItem.type === 'song') queueRef.current = mediaItem
|
||||
}}
|
||||
>
|
||||
<i slot="icon" class="fa-solid fa-play text-xl" />
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// import { FastAverageColor } from 'fast-average-color'
|
||||
import Slider from '$lib/components/util/slider.svelte'
|
||||
|
||||
$: currentlyPlaying = $queue.current()
|
||||
$: currentlyPlaying = $queue.current
|
||||
|
||||
let expanded = false
|
||||
|
||||
let paused = true,
|
||||
shuffle = false,
|
||||
@@ -14,7 +16,8 @@
|
||||
let volume: number,
|
||||
muted = false
|
||||
|
||||
$: if (volume) localStorage.setItem('volume', volume.toString())
|
||||
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
|
||||
$: if (volume && !muted) localStorage.setItem('volume', volume.toString())
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
seconds = Math.floor(seconds)
|
||||
@@ -25,6 +28,18 @@
|
||||
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
$: if (currentlyPlaying) updateMediaSession(currentlyPlaying)
|
||||
const updateMediaSession = (media: Song) => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: media.name,
|
||||
artist: media.artists?.map((artist) => artist.name).join(', ') || media.createdBy?.name,
|
||||
album: media.album?.name,
|
||||
artwork: [{ src: `/api/remoteImage?url=${media.thumbnail}`, sizes: '256x256', type: 'image/png' }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const storedVolume = localStorage.getItem('volume')
|
||||
if (storedVolume) {
|
||||
@@ -33,6 +48,14 @@
|
||||
localStorage.setItem('volume', '0.5')
|
||||
volume = 0.5
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('play', () => (paused = false))
|
||||
navigator.mediaSession.setActionHandler('pause', () => (paused = true))
|
||||
navigator.mediaSession.setActionHandler('stop', () => $queue.clear())
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => $queue.next())
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => $queue.previous())
|
||||
}
|
||||
})
|
||||
|
||||
let currentTime: number = 0
|
||||
@@ -42,84 +65,211 @@
|
||||
let progressBar: Slider
|
||||
let durationTimestamp: HTMLSpanElement
|
||||
|
||||
let expandedCurrentTimeTimestamp: HTMLSpanElement
|
||||
let expandedProgressBar: Slider
|
||||
let expandedDurationTimestamp: HTMLSpanElement
|
||||
|
||||
let seeking: boolean = false
|
||||
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
|
||||
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
|
||||
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
|
||||
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
|
||||
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
|
||||
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
|
||||
</script>
|
||||
|
||||
{#if currentlyPlaying}
|
||||
<main transition:slide class="relative m-4 grid h-20 grid-cols-[minmax(auto,_20rem)_auto_minmax(auto,_20rem)] gap-4 overflow-clip rounded-xl bg-neutral-925 text-white transition-colors duration-1000">
|
||||
<section class="flex gap-3">
|
||||
<div class="relative h-full w-20 min-w-20">
|
||||
{#key currentlyPlaying}
|
||||
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={currentlyPlaying.thumbnail});" />
|
||||
{/key}
|
||||
</div>
|
||||
<section class="flex flex-col justify-center gap-1">
|
||||
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
||||
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}</div>
|
||||
</section>
|
||||
</section>
|
||||
<section class="flex min-w-max flex-col items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-3 text-lg">
|
||||
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
|
||||
<i class="fa-solid fa-shuffle" />
|
||||
</button>
|
||||
<button class="aspect-square h-8">
|
||||
<i class="fa-solid fa-backward-step" />
|
||||
</button>
|
||||
<button on:click={() => (paused = !paused)} class="grid aspect-square h-8 place-items-center rounded-full bg-white">
|
||||
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
|
||||
</button>
|
||||
<button class="aspect-square h-8">
|
||||
<i class="fa-solid fa-forward-step" />
|
||||
</button>
|
||||
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
|
||||
<i class="fa-solid fa-repeat" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-items-center gap-2">
|
||||
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
|
||||
<div class="w-72">
|
||||
<Slider
|
||||
bind:this={progressBar}
|
||||
max={duration}
|
||||
on:seeking={(event) => {
|
||||
currentTimeTimestamp.innerText = formatTime(event.detail.value)
|
||||
seeking = true
|
||||
}}
|
||||
on:seeked={(event) => {
|
||||
currentTime = event.detail.value
|
||||
seeking = false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span bind:this={durationTimestamp} class="w-16 text-left" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex items-center justify-end pr-2 text-lg">
|
||||
<div id="volume-slider" class="flex h-10 w-fit flex-shrink-0 flex-row-reverse items-center gap-2">
|
||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
||||
<i class="fa-solid {volume > 0.5 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center text-base" />
|
||||
</button>
|
||||
<div id="slider-wrapper" class="w-0 transition-all duration-500">
|
||||
<Slider bind:value={volume} max={1} />
|
||||
</div>
|
||||
</div>
|
||||
<button class="aspect-square h-8" on:click={() => console.log('close')}>
|
||||
<i class="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</section>
|
||||
<div transition:slide class="{expanded ? 'h-full w-full' : 'm-4 h-20 w-[calc(100%_-_32px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all duration-500">
|
||||
{#if !expanded}
|
||||
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="relative grid h-20 w-full grid-cols-[minmax(auto,_20rem)_auto_minmax(auto,_20rem)] gap-4">
|
||||
<section class="flex gap-3">
|
||||
<div class="relative h-full w-20 min-w-20">
|
||||
{#key currentlyPlaying}
|
||||
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={currentlyPlaying.thumbnail});" />
|
||||
{/key}
|
||||
</div>
|
||||
<section class="flex flex-col justify-center gap-1">
|
||||
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
||||
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}</div>
|
||||
</section>
|
||||
</section>
|
||||
<section class="flex min-w-max flex-col items-center justify-center gap-1">
|
||||
<div class="flex items-center gap-3 text-lg">
|
||||
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
|
||||
<i class="fa-solid fa-shuffle" />
|
||||
</button>
|
||||
<button class="aspect-square h-8" on:click={() => $queue.previous()}>
|
||||
<i class="fa-solid fa-backward-step" />
|
||||
</button>
|
||||
<button on:click={() => (paused = !paused)} class="grid aspect-square h-8 place-items-center rounded-full bg-white">
|
||||
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
|
||||
</button>
|
||||
<button class="aspect-square h-8" on:click={() => $queue.next()}>
|
||||
<i class="fa-solid fa-forward-step" />
|
||||
</button>
|
||||
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
|
||||
<i class="fa-solid fa-repeat" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-items-center gap-2">
|
||||
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
|
||||
<div class="w-72">
|
||||
<Slider
|
||||
bind:this={progressBar}
|
||||
max={duration}
|
||||
on:seeking={(event) => {
|
||||
currentTimeTimestamp.innerText = formatTime(event.detail.value)
|
||||
seeking = true
|
||||
}}
|
||||
on:seeked={(event) => {
|
||||
currentTime = event.detail.value
|
||||
seeking = false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span bind:this={durationTimestamp} class="w-16 text-left" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex items-center justify-end gap-2 pr-2 text-lg">
|
||||
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
||||
<i class="fa-solid {volume > 0.5 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
||||
</button>
|
||||
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
||||
<Slider bind:value={volume} max={1} />
|
||||
</div>
|
||||
</div>
|
||||
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
|
||||
<i class="fa-solid fa-expand" />
|
||||
</button>
|
||||
<button class="aspect-square h-8" on:click={() => $queue.clear()}>
|
||||
<i class="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
{:else}
|
||||
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnail});">
|
||||
<section class="song-queue-wrapper h-full px-24 py-20">
|
||||
<section class="relative">
|
||||
{#key currentlyPlaying}
|
||||
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnail}" alt="" />
|
||||
{/key}
|
||||
</section>
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="ml-2 text-2xl">Up next</div>
|
||||
{#each $queue.list as item, index}
|
||||
{@const isCurrent = item === currentlyPlaying}
|
||||
<button
|
||||
on:click={() => {
|
||||
if (!isCurrent) $queue.current = item
|
||||
}}
|
||||
class="queue-item w-full items-center gap-3 rounded-xl p-3 {isCurrent ? 'bg-[rgba(64,_64,_64,_0.5)]' : 'bg-[rgba(10,_10,_10,_0.5)]'}"
|
||||
>
|
||||
<div class="justify-self-center">{index + 1}</div>
|
||||
<img class="justify-self-center" src="/api/remoteImage?url={item.thumbnail}" alt="" draggable="false" />
|
||||
<div class="justify-items-left text-left">
|
||||
<div class="line-clamp-2">{item.name}</div>
|
||||
<div class="mt-[.15rem] text-neutral-500">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}</div>
|
||||
</div>
|
||||
<span class="text-right">{item.duration ?? ''}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
</section>
|
||||
<section class="px-8">
|
||||
<div class="progress-bar-expanded mb-8">
|
||||
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
|
||||
<Slider
|
||||
bind:this={expandedProgressBar}
|
||||
max={duration}
|
||||
on:seeking={(event) => {
|
||||
expandedCurrentTimeTimestamp.innerText = formatTime(event.detail.value)
|
||||
seeking = true
|
||||
}}
|
||||
on:seeked={(event) => {
|
||||
currentTime = event.detail.value
|
||||
seeking = false
|
||||
}}
|
||||
/>
|
||||
<span bind:this={expandedDurationTimestamp} class="text-left" />
|
||||
</div>
|
||||
<div class="expanded-controls">
|
||||
<div>
|
||||
<div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div>
|
||||
<div class="line-clamp-1 text-lg">
|
||||
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}{currentlyPlaying.album ? ` - ${currentlyPlaying.album.name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full items-center justify-center gap-2 text-2xl">
|
||||
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-16">
|
||||
<i class="fa-solid fa-shuffle" />
|
||||
</button>
|
||||
<button class="aspect-square h-16" on:click={() => $queue.previous()}>
|
||||
<i class="fa-solid fa-backward-step" />
|
||||
</button>
|
||||
<button on:click={() => (paused = !paused)} class="grid aspect-square h-16 place-items-center rounded-full bg-white">
|
||||
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
|
||||
</button>
|
||||
<button class="aspect-square h-16" on:click={() => $queue.next()}>
|
||||
<i class="fa-solid fa-forward-step" />
|
||||
</button>
|
||||
<button on:click={() => (repeat = !repeat)} class="aspect-square h-16">
|
||||
<i class="fa-solid fa-repeat" />
|
||||
</button>
|
||||
</div>
|
||||
<section class="flex items-center justify-end gap-2 text-xl">
|
||||
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
||||
<i class="fa-solid {volume > 0.5 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
||||
</button>
|
||||
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
||||
<Slider bind:value={volume} max={1} />
|
||||
</div>
|
||||
</div>
|
||||
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
|
||||
<i class="fa-solid fa-compress" />
|
||||
</button>
|
||||
<button class="aspect-square h-8" on:click={() => $queue.clear()}>
|
||||
<i class="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{/if}
|
||||
<audio autoplay bind:paused bind:volume bind:currentTime bind:duration on:ended={() => $queue.next()} src="/api/audio?connection={currentlyPlaying.connection}&id={currentlyPlaying.id}" />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#volume-slider:hover > #slider-wrapper {
|
||||
width: 6rem;
|
||||
.expanded-player {
|
||||
display: grid;
|
||||
grid-template-rows: auto 12rem;
|
||||
/* background: linear-gradient(to left, rgba(16, 16, 16, 0.9), rgb(16, 16, 16)), var(--currentlyPlayingImage); */
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
}
|
||||
#slider-wrapper:focus-within {
|
||||
width: 6rem;
|
||||
.song-queue-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 4rem;
|
||||
}
|
||||
.queue-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem 50px auto min-content;
|
||||
}
|
||||
.queue-item:hover {
|
||||
background-color: rgba(64, 64, 64, 0.5);
|
||||
}
|
||||
.progress-bar-expanded {
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto min-content;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.expanded-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content 1fr;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,4 +31,7 @@
|
||||
if (event.key === 'Enter') triggerSearch(searchInput.value)
|
||||
}}
|
||||
/>
|
||||
<button class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary" on:click|preventDefault={() => (searchInput.value = '')}>
|
||||
<i class="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</search>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
|
||||
export let value = 0
|
||||
export let max = 100
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
|
||||
$: trackThumb(value)
|
||||
onMount(() => trackThumb(value))
|
||||
|
||||
const handleKeyPress = (key: string) => {
|
||||
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < 1) return (value += 1)
|
||||
|
||||
@@ -8,7 +8,7 @@ export class YouTubeMusic implements Connection {
|
||||
public readonly id: string
|
||||
private readonly userId: string
|
||||
private readonly ytUserId: string
|
||||
private accessToken: string
|
||||
private currentAccessToken: string
|
||||
private readonly refreshToken: string
|
||||
private expiry: number
|
||||
|
||||
@@ -16,63 +16,67 @@ export class YouTubeMusic implements Connection {
|
||||
this.id = id
|
||||
this.userId = userId
|
||||
this.ytUserId = youtubeUserId
|
||||
this.accessToken = accessToken
|
||||
this.currentAccessToken = accessToken
|
||||
this.refreshToken = refreshToken
|
||||
this.expiry = expiry
|
||||
}
|
||||
|
||||
private headers = async () => {
|
||||
return new Headers({
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
|
||||
accept: '*/*',
|
||||
'accept-encoding': 'gzip, deflate',
|
||||
'content-type': 'application/json',
|
||||
'content-encoding': 'gzip',
|
||||
origin: 'https://music.youtube.com',
|
||||
Cookie: 'SOCS=CAI;',
|
||||
authorization: `Bearer ${await this.getAccessToken()}`,
|
||||
'X-Goog-Request-Time': `${Date.now()}`,
|
||||
})
|
||||
private get innertubeRequestHeaders() {
|
||||
return (async () => {
|
||||
return new Headers({
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
|
||||
accept: '*/*',
|
||||
'accept-encoding': 'gzip, deflate',
|
||||
'content-type': 'application/json',
|
||||
'content-encoding': 'gzip',
|
||||
origin: 'https://music.youtube.com',
|
||||
Cookie: 'SOCS=CAI;',
|
||||
authorization: `Bearer ${await this.accessToken}`,
|
||||
'X-Goog-Request-Time': `${Date.now()}`,
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
private getAccessToken = async (): Promise<string> => {
|
||||
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
|
||||
const MAX_TRIES = 3
|
||||
let tries = 0
|
||||
const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, refresh_token: this.refreshToken, grant_type: 'refresh_token' }
|
||||
private get accessToken(): Promise<string> {
|
||||
return (async () => {
|
||||
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
|
||||
const MAX_TRIES = 3
|
||||
let tries = 0
|
||||
const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, refresh_token: this.refreshToken, grant_type: 'refresh_token' }
|
||||
|
||||
while (tries < MAX_TRIES) {
|
||||
++tries
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(refreshDetails),
|
||||
}).catch((reason) => {
|
||||
console.error(`Fetch to refresh endpoint failed: ${reason}`)
|
||||
return null
|
||||
})
|
||||
if (!response || !response.ok) continue
|
||||
while (tries < MAX_TRIES) {
|
||||
++tries
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(refreshDetails),
|
||||
}).catch((reason) => {
|
||||
console.error(`Fetch to refresh endpoint failed: ${reason}`)
|
||||
return null
|
||||
})
|
||||
if (!response || !response.ok) continue
|
||||
|
||||
const { access_token, expires_in } = await response.json()
|
||||
const expiry = Date.now() + expires_in * 1000
|
||||
return { accessToken: access_token, expiry }
|
||||
const { access_token, expires_in } = await response.json()
|
||||
const expiry = Date.now() + expires_in * 1000
|
||||
return { accessToken: access_token, expiry }
|
||||
}
|
||||
|
||||
throw new Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`)
|
||||
}
|
||||
|
||||
throw new Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`)
|
||||
}
|
||||
if (this.expiry < Date.now()) {
|
||||
const { accessToken, expiry } = await refreshTokens()
|
||||
DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry })
|
||||
this.currentAccessToken = accessToken
|
||||
this.expiry = expiry
|
||||
}
|
||||
|
||||
if (this.expiry < Date.now()) {
|
||||
const { accessToken, expiry } = await refreshTokens()
|
||||
DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry })
|
||||
this.accessToken = accessToken
|
||||
this.expiry = expiry
|
||||
}
|
||||
|
||||
return this.accessToken
|
||||
return this.currentAccessToken
|
||||
})()
|
||||
}
|
||||
|
||||
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'youtube-music' }>> => {
|
||||
public async getConnectionInfo(): Promise<Extract<ConnectionInfo, { type: 'youtube-music' }>> {
|
||||
const youtube = google.youtube('v3')
|
||||
const access_token = await this.getAccessToken().catch(() => {
|
||||
const access_token = await this.accessToken.catch(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -84,17 +88,10 @@ export class YouTubeMusic implements Connection {
|
||||
profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
type: 'youtube-music',
|
||||
youtubeUserId: this.ytUserId,
|
||||
username,
|
||||
profilePicture,
|
||||
}
|
||||
return { id: this.id, userId: this.userId, type: 'youtube-music', youtubeUserId: this.ytUserId, username, profilePicture }
|
||||
}
|
||||
|
||||
public search = async (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> => {
|
||||
public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> {
|
||||
// Figure out how to handle Library and Uploads
|
||||
// Depending on how I want to handle the playlist & library sync feature
|
||||
|
||||
@@ -106,7 +103,7 @@ export class YouTubeMusic implements Connection {
|
||||
}
|
||||
|
||||
const searchResulsts: InnerTube.SearchResponse = await fetch(`https://music.youtube.com/youtubei/v1/search`, {
|
||||
headers: await this.headers(),
|
||||
headers: await this.innertubeRequestHeaders,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
query: searchTerm,
|
||||
@@ -146,9 +143,9 @@ export class YouTubeMusic implements Connection {
|
||||
return parsedSearchResults
|
||||
}
|
||||
|
||||
public getRecommendations = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
|
||||
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
|
||||
const browseResponse: InnerTube.BrowseResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
|
||||
headers: await this.headers(),
|
||||
headers: await this.innertubeRequestHeaders,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
browseId: 'FEmusic_home',
|
||||
@@ -163,6 +160,7 @@ export class YouTubeMusic implements Connection {
|
||||
}).then((response) => response.json())
|
||||
|
||||
const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||
console.log(JSON.stringify(contents))
|
||||
|
||||
const recommendations: (Song | Album | Artist | Playlist)[] = []
|
||||
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
|
||||
@@ -179,7 +177,7 @@ export class YouTubeMusic implements Connection {
|
||||
return recommendations
|
||||
}
|
||||
|
||||
public getAudioStream = async (id: string, range: string | null): Promise<Response> => {
|
||||
public async getAudioStream(id: string, range: string | null): Promise<Response> {
|
||||
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`)
|
||||
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
|
||||
|
||||
@@ -189,7 +187,7 @@ export class YouTubeMusic implements Connection {
|
||||
}
|
||||
}
|
||||
|
||||
const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => {
|
||||
function parseTwoRowItemRenderer(connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist {
|
||||
const name = rowContent.title.runs[0].text
|
||||
const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
|
||||
@@ -206,6 +204,17 @@ const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.music
|
||||
}
|
||||
}
|
||||
|
||||
let album: Song['album']
|
||||
rowContent.menu.menuRenderer.items.forEach((menuOption) => {
|
||||
if (
|
||||
'menuNavigationItemRenderer' in menuOption &&
|
||||
'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint &&
|
||||
menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
|
||||
) {
|
||||
album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId, name: 'NEED TO FIND A WAY TO GET ALBUM NAME FROM ID' }
|
||||
}
|
||||
})
|
||||
|
||||
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
||||
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
|
||||
return { connection, id, name, type: 'song', artists, createdBy, thumbnail } satisfies Song
|
||||
@@ -224,7 +233,7 @@ const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.music
|
||||
}
|
||||
}
|
||||
|
||||
const parseResponsiveListItemRenderer = (connection: string, listContent: InnerTube.musicResponsiveListItemRenderer): Song | Album | Artist | Playlist => {
|
||||
function parseResponsiveListItemRenderer(connection: string, listContent: InnerTube.musicResponsiveListItemRenderer): Song | Album | Artist | Playlist {
|
||||
const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
||||
const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
|
||||
@@ -245,7 +254,7 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
|
||||
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
|
||||
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
||||
let album: Song['album']
|
||||
if (column2run?.navigationEndpoint && column2run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
|
||||
if (column2run?.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
|
||||
album = { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text }
|
||||
}
|
||||
|
||||
@@ -265,7 +274,7 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
|
||||
}
|
||||
}
|
||||
|
||||
const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.musicCardShelfRenderer): Song | Album | Artist | Playlist => {
|
||||
function parseMusicCardShelfRenderer(connection: string, cardContent: InnerTube.musicCardShelfRenderer): Song | Album | Artist | Playlist {
|
||||
const name = cardContent.title.runs[0].text
|
||||
const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||
|
||||
@@ -303,7 +312,7 @@ const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.
|
||||
}
|
||||
}
|
||||
|
||||
const refineThumbnailUrl = (urlString: string): string => {
|
||||
function refineThumbnailUrl(urlString: string): string {
|
||||
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
|
||||
|
||||
const url = new URL(urlString)
|
||||
@@ -322,7 +331,7 @@ const refineThumbnailUrl = (urlString: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (): string => {
|
||||
function formatDate(): string {
|
||||
const currentDate = new Date()
|
||||
const year = currentDate.getUTCFullYear()
|
||||
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
|
||||
@@ -474,6 +483,42 @@ declare namespace InnerTube {
|
||||
| {
|
||||
browseEndpoint: browseEndpoint
|
||||
}
|
||||
menu: {
|
||||
menuRenderer: {
|
||||
items: Array<
|
||||
| {
|
||||
menuNavigationItemRenderer: {
|
||||
text: {
|
||||
runs: [
|
||||
{
|
||||
text: 'Start radio' | 'Save to playlist' | 'Go to album' | 'Go to artist' | 'Share'
|
||||
},
|
||||
]
|
||||
}
|
||||
navigationEndpoint:
|
||||
| {
|
||||
watchEndpoint: watchEndpoint
|
||||
}
|
||||
| {
|
||||
addToPlaylistEndpoint: unknown
|
||||
}
|
||||
| {
|
||||
browseEndpoint: browseEndpoint
|
||||
}
|
||||
| {
|
||||
shareEntityEndpoint: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
menuServiceItemRenderer: unknown
|
||||
}
|
||||
| {
|
||||
toggleMenuServiceItemRenderer: unknown
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type musicResponsiveListItemRenderer = {
|
||||
|
||||
@@ -9,44 +9,66 @@ const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/yt
|
||||
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
|
||||
|
||||
class Queue {
|
||||
private currentPos: number | null
|
||||
private currentPosition: number // -1 means there is no current position
|
||||
private songs: Song[]
|
||||
|
||||
constructor() {
|
||||
this.currentPos = null
|
||||
this.currentPosition = -1
|
||||
this.songs = []
|
||||
}
|
||||
|
||||
public enqueue = (...songs: Song[]) => {
|
||||
this.songs.push(...songs)
|
||||
writeableQueue.set(this)
|
||||
}
|
||||
|
||||
public next = () => {
|
||||
if (this.songs.length === 0) return
|
||||
|
||||
if (!this.currentPos) {
|
||||
this.currentPos = 0
|
||||
} else {
|
||||
if (!(this.songs.length > this.currentPos + 1)) return
|
||||
this.currentPos += 1
|
||||
}
|
||||
|
||||
writeableQueue.set(this)
|
||||
}
|
||||
|
||||
public current = () => {
|
||||
get current() {
|
||||
if (this.songs.length > 0) {
|
||||
if (!this.currentPos) this.currentPos = 0
|
||||
return this.songs[this.currentPos]
|
||||
if (this.currentPosition === -1) this.currentPosition = 0
|
||||
return this.songs[this.currentPosition]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
public getSongs = () => {
|
||||
set current(newSong: Song | null) {
|
||||
if (newSong === null) {
|
||||
this.currentPosition = -1
|
||||
} else {
|
||||
const queuePosition = this.songs.findIndex((song) => song === newSong)
|
||||
if (queuePosition < 0) {
|
||||
this.songs = [newSong]
|
||||
this.currentPosition = 0
|
||||
} else {
|
||||
this.currentPosition = queuePosition
|
||||
}
|
||||
}
|
||||
writableQueue.set(this)
|
||||
}
|
||||
|
||||
get list() {
|
||||
return this.songs
|
||||
}
|
||||
|
||||
public next() {
|
||||
if (this.songs.length === 0 || !(this.songs.length > this.currentPosition + 1)) return
|
||||
|
||||
this.currentPosition += 1
|
||||
writableQueue.set(this)
|
||||
}
|
||||
|
||||
public previous() {
|
||||
if (this.songs.length === 0 || this.currentPosition <= 0) return
|
||||
|
||||
this.currentPosition -= 1
|
||||
writableQueue.set(this)
|
||||
}
|
||||
|
||||
public enqueue(...songs: Song[]) {
|
||||
this.songs.push(...songs)
|
||||
writableQueue.set(this)
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.currentPosition = -1
|
||||
this.songs = []
|
||||
writableQueue.set(this)
|
||||
}
|
||||
}
|
||||
|
||||
const writeableQueue: Writable<Queue> = writable(new Queue())
|
||||
export const queue: Readable<Queue> = readonly(writeableQueue)
|
||||
const writableQueue: Writable<Queue> = writable(new Queue())
|
||||
export const queue: Readable<Queue> = readonly(writableQueue)
|
||||
|
||||
Reference in New Issue
Block a user