Started on media player
This commit is contained in:
@@ -45,9 +45,9 @@
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
<section class="absolute bottom-0 z-40 max-h-full w-full">
|
||||
<section class="absolute bottom-0 z-40 grid max-h-full w-full place-items-center">
|
||||
{#if $currentlyPlaying}
|
||||
<MediaPlayer currentlyPlaying={$currentlyPlaying} />
|
||||
<MediaPlayer song={$currentlyPlaying} />
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,12 @@ import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
const getRecommendations = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
|
||||
const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
return recommendationResponse.recommendations
|
||||
}
|
||||
|
||||
const recommendations: (Song | Album | Artist | Playlist)[] = recommendationResponse.recommendations
|
||||
|
||||
return { recommendations }
|
||||
return { recommendations: getRecommendations() }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
</script>
|
||||
|
||||
<div id="main">
|
||||
<ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} />
|
||||
{#await data.recommendations then recommendations}
|
||||
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
|
||||
{/await}
|
||||
<!-- <h1 class="mb-6 text-4xl"><strong>Listen Again</strong></h1>
|
||||
<div class="flex flex-wrap justify-between gap-6">
|
||||
{#each data.recommendations as recommendation}
|
||||
|
||||
@@ -4,11 +4,14 @@ import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
const query = url.searchParams.get('query')
|
||||
if (query) {
|
||||
const searchResults: { searchResults: (Song | Album | Artist | Playlist)[] } = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
const getSearchResults = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
|
||||
const searchResults = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
return searchResults.searchResults
|
||||
}
|
||||
|
||||
return searchResults
|
||||
return { searchResults: getSearchResults() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
</script>
|
||||
|
||||
{#if data.searchResults}
|
||||
{#each data.searchResults as searchResult}
|
||||
<div>{searchResult.name} - {searchResult.type}</div>
|
||||
{/each}
|
||||
{#await data.searchResults then searchResults}
|
||||
{#each searchResults as searchResult}
|
||||
<div>{searchResult.name} - {searchResult.type}</div>
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -7,14 +7,15 @@ import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
|
||||
import { google } from 'googleapis'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
const getConnectionInfo = async (): Promise<ConnectionInfo[]> => {
|
||||
const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
return connectionInfoResponse.connections
|
||||
}
|
||||
|
||||
const connections: ConnectionInfo[] = connectionInfoResponse.connections
|
||||
|
||||
return { connections }
|
||||
return { connections: getConnectionInfo() }
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
import ConnectionProfile from './connectionProfile.svelte'
|
||||
import { enhance } from '$app/forms'
|
||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let data: PageServerData & LayoutData
|
||||
let connections = data.connections
|
||||
let connections: ConnectionInfo[]
|
||||
onMount(async () => {
|
||||
connections = await data.connections
|
||||
})
|
||||
|
||||
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||
@@ -79,7 +83,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const profileActions: SubmitFunction = ({ action, cancel }) => {
|
||||
const profileActions: SubmitFunction = () => {
|
||||
return ({ result }) => {
|
||||
if (result.type === 'failure') {
|
||||
return ($newestAlert = ['warning', result.data?.message])
|
||||
@@ -134,9 +138,11 @@
|
||||
</div>
|
||||
</section>
|
||||
<div id="connection-profile-grid" class="grid gap-8">
|
||||
{#each connections as connectionInfo}
|
||||
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
||||
{/each}
|
||||
{#if connections}
|
||||
{#each connections as connectionInfo}
|
||||
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if newConnectionModal !== null}
|
||||
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
|
||||
@@ -149,6 +155,6 @@
|
||||
background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));
|
||||
}
|
||||
#connection-profile-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Services from '$lib/services.json'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import Toggle from '$lib/components/util/toggle.svelte'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import { fly } from 'svelte/transition'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import { enhance } from '$app/forms'
|
||||
|
||||
export let connectionInfo: ConnectionInfo
|
||||
@@ -11,8 +10,6 @@
|
||||
|
||||
$: serviceData = Services[connectionInfo.type]
|
||||
|
||||
let showModal = false
|
||||
|
||||
const subHeaderItems: string[] = []
|
||||
if ('username' in connectionInfo) {
|
||||
subHeaderItems.push(connectionInfo.username)
|
||||
@@ -22,7 +19,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
|
||||
<section class="relative overflow-clip rounded-lg" transition:fly={{ x: 50 }}>
|
||||
<div class="absolute -z-10 h-full w-full bg-black bg-cover bg-right bg-no-repeat brightness-[25%]" style="background-image: url({serviceData.icon}); mask-image: linear-gradient(to left, black, rgba(0, 0, 0, 0));" />
|
||||
<header class="flex h-20 items-center gap-4 p-4">
|
||||
<div class="relative aspect-square h-full p-1">
|
||||
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
|
||||
@@ -31,24 +29,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div>{serviceData.displayName} - {connectionInfo.id}</div>
|
||||
<div>{serviceData.displayName}</div>
|
||||
<div class="text-sm text-neutral-500">
|
||||
{subHeaderItems.join(' - ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2">
|
||||
<IconButton halo={true} on:click={() => (showModal = !showModal)}>
|
||||
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" />
|
||||
</IconButton>
|
||||
{#if showModal}
|
||||
<form use:enhance={submitFunction} method="post" class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs">
|
||||
<button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
|
||||
<i class="fa-solid fa-link-slash mr-1" />
|
||||
Delete Connection
|
||||
</button>
|
||||
<input type="hidden" value={connectionInfo.id} name="connectionId" />
|
||||
</form>
|
||||
{/if}
|
||||
<div class="relative ml-auto flex flex-row-reverse gap-2">
|
||||
<form action="?/deleteConnection" method="post" use:enhance={submitFunction}>
|
||||
<input type="hidden" name="connectionId" value={connectionInfo.id} />
|
||||
<button class="aspect-square h-8 text-2xl text-neutral-500 hover:text-lazuli-primary">
|
||||
<i class="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<hr class="mx-2 border-t-2 border-neutral-600" />
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { Connections } from '$lib/server/connections'
|
||||
import ytdl from 'ytdl-core'
|
||||
|
||||
export const GET: RequestHandler = async ({ url, request }) => {
|
||||
const connectionId = url.searchParams.get('connectionId')
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const connectionId = url.searchParams.get('connection')
|
||||
const id = url.searchParams.get('id')
|
||||
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
|
||||
const range = request.headers.get('range')
|
||||
if (!range) return new Response('Missing Range Header')
|
||||
|
||||
const videourl = `http://www.youtube.com/watch?v=${id}`
|
||||
const connection = Connections.getConnections([connectionId])[0]
|
||||
const stream = await connection.getAudioStream(id)
|
||||
|
||||
const videoInfo = await ytdl.getInfo(videourl)
|
||||
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
|
||||
if (!stream.body) throw new Error(`Audio fetch did not return valid ReadableStream (Connection: ${connection.id})`)
|
||||
|
||||
const audioSize = format.contentLength
|
||||
const CHUNK_SIZE = 5 * 10 ** 6
|
||||
const start = Number(range.replace(/\D/g, ''))
|
||||
const end = Math.min(start + CHUNK_SIZE, Number(audioSize) - 1)
|
||||
const contentLength = end - start + 1
|
||||
const contentLength = stream.headers.get('Content-Length')
|
||||
if (!contentLength || isNaN(Number(contentLength))) throw new Error(`Audio fetch did not return valid Content-Length header (Connection: ${connection.id})`)
|
||||
|
||||
const headers = new Headers({
|
||||
'Content-Range': `bytes ${start}-${end}/${audioSize}`,
|
||||
'Content-Range': `bytes 0-${Number(contentLength) - 1}/${contentLength}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': contentLength.toString(),
|
||||
'Content-Type': 'audio/webm',
|
||||
})
|
||||
|
||||
const partialStream = ytdl(videourl, { format, range: { start, end } })
|
||||
|
||||
// @ts-ignore IDK enough about streaming to understand what the problem is here
|
||||
// but it appears that ytdl has a custom version of a readable stream type they use internally
|
||||
// and is what gets returned by ytdl(). Svelte will only allow you to send back the type ReadableStream
|
||||
// so it ts gets mad if you try to send back their internal type.
|
||||
// IDK to me a custom readable type seems incredibly stupid but what do I know?
|
||||
// Currently haven't found a way to convert their readable to ReadableStream type, casting doesn't seem to work either.
|
||||
return new Response(partialStream, { status: 206, headers })
|
||||
return new Response(stream.body, { status: 206, headers })
|
||||
}
|
||||
|
||||
13
src/routes/api/remoteImage/+server.ts
Normal file
13
src/routes/api/remoteImage/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
// const connectionId = url.searchParams.get('connection')
|
||||
// const id = url.searchParams.get('id')
|
||||
// if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
|
||||
const imageUrl = url.searchParams.get('url')
|
||||
if (!imageUrl) return new Response('Missing url', { status: 400 })
|
||||
|
||||
const image = await fetch(imageUrl).then((response) => response.arrayBuffer())
|
||||
|
||||
return new Response(image)
|
||||
}
|
||||
Reference in New Issue
Block a user