Started on media player

This commit is contained in:
Eclypsed
2024-04-09 00:10:23 -04:00
parent c5408d76b6
commit 8e52bd71c4
19 changed files with 1095 additions and 319 deletions

View File

@@ -40,6 +40,8 @@
<span class="mr-0.5 text-sm">,</span>
{/if}
{/each}
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
<span class="text-sm">{mediaItem.createdBy.name}</span>
{/if}
</div>
</div>

View File

@@ -1,19 +1,112 @@
<script lang="ts">
export let currentlyPlaying: Song
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { currentlyPlaying } from '$lib/stores'
import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte'
export let song: Song
let playing = false,
shuffle = false,
repeat = false
let bgColor = 'black',
primaryColor = 'var(--lazuli-primary)'
const fac = new FastAverageColor()
$: fac.getColorAsync(`/api/remoteImage?url=${song.thumbnail}`, { algorithm: 'dominant' }).then((color) => (bgColor = color.rgb))
$: fac.getColorAsync(`/api/remoteImage?url=${song.thumbnail}`, { algorithm: 'simple' }).then((color) => (primaryColor = color.rgb))
// let audioSource: HTMLAudioElement, progressBar: HTMLInputElement
// onMount(() => (audioSource.volume = 0.4))
</script>
<main class="h-full w-full">
<div class="bg-red-300">
<audio controls>
<source src="/api/audio?id=KfmrhlGCfWk&connectionId=cae88864-e116-4ce8-b3cc-5383eae0b781&apikey=2ff052272eeb44628c97314e09f384c10ae7fb31d8a40630442d3cb417512574" type="audio/webm" />
</audio>
</div>
<div class="bg-green-300">{currentlyPlaying.type}</div>
<main class="relative m-4 flex h-24 flex-grow-0 gap-4 overflow-clip rounded-xl text-white transition-colors duration-1000" style="background-color: {bgColor};">
<img src={song.thumbnail} alt="" class="aspect-square max-h-full object-cover" />
<section class="flex w-96 flex-col justify-center gap-1">
<div class="line-clamp-2">{song.name}</div>
<div class="text-sm text-neutral-400">{song.artists?.map((artist) => artist.name) || song.createdBy?.name}</div>
</section>
<section class="flex w-96 flex-col items-center justify-center gap-4">
<div class="flex items-center gap-3 text-xl">
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
<i class="fa-solid fa-shuffle" style="color: {shuffle ? primaryColor : 'rgb(163, 163, 163)'};" />
</button>
<button class="aspect-square h-8">
<i class="fa-solid fa-backward-step" />
</button>
<button on:click={() => (playing = !playing)} class="grid aspect-square h-10 place-items-center rounded-full bg-white">
<i class="fa-solid {playing ? 'fa-pause' : 'fa-play'} 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" style="color: {repeat ? primaryColor : 'rgb(163, 163, 163)'};" />
</button>
</div>
<Slider sliderColor={primaryColor} />
</section>
</main>
<style>
<!-- <main class="h-screen w-full">
<div class="relative h-full overflow-hidden">
<div class="absolute z-0 flex h-full w-full items-center justify-items-center bg-neutral-900">
<div class="absolute z-10 h-full w-full backdrop-blur-3xl"></div>
{#key song}
<img in:fade src={song.thumbnail} alt="" class="absolute h-full w-full object-cover brightness-50" />
{/key}
</div>
<div class="absolute grid h-full w-full grid-rows-[auto_8rem_3rem_6rem] justify-items-center p-8">
{#key song}
<img in:fade src={song.thumbnail} alt="" class="h-full min-h-[8rem] overflow-hidden rounded-xl object-contain p-2" />
{/key}
<div in:fade class="flex flex-col items-center justify-center gap-1 px-8 text-center font-notoSans">
{#key song}
<span class="text-3xl text-neutral-300">{song.name}</span>
{#if song.album?.name}
<span class="text-xl text-neutral-300">{song.album.name}</span>
{/if}
<span class="text-xl text-neutral-300">{song.artists?.map((artist) => artist.name).join(' / ') || song.createdBy?.name}</span>
{/key}
</div>
<input
id="progress-bar"
on:mouseup={() => (audioSource.currentTime = audioSource.duration * Number(progressBar.value))}
type="range"
value="0"
min="0"
max="1"
step="any"
class="w-[90%] cursor-pointer rounded-lg bg-gray-400"
/>
<div class="flex h-full w-11/12 justify-around overflow-hidden text-3xl text-white">
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-backward-step" />
</button>
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class={playing ? 'fa-solid fa-pause' : 'fa-solid fa-play'} />
</button>
<button on:click={() => ($currentlyPlaying = null)} class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-stop" />
</button>
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-forward-step" />
</button>
</div>
</div>
</div>
<div class="no-scrollbar flex w-full flex-col items-center divide-y-[1px] divide-[#353535] overflow-y-scroll bg-neutral-900 p-4">
<div>This is where playlist items go</div>
</div>
{#key song}
<audio bind:this={audioSource} id="audio" class="hidden" src="/api/audio?id={song.id}&connection={song.connection}" />
{/key}
</main> -->
<!-- <style>
main {
display: grid;
grid-template-columns: 2fr 1fr;
}
</style>
</style> -->

View File

@@ -1,5 +1,6 @@
<script lang="ts">
export let value = 0
export let sliderColor: string | undefined = undefined
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
@@ -18,7 +19,8 @@
<div
id="slider-track"
class="relative isolate h-1 w-full rounded-full bg-neutral-600"
class="relative isolate h-1.5 w-full rounded-full bg-neutral-600"
style="--slider-color: {sliderColor || 'var(--lazuli-primary)'}"
role="slider"
tabindex="0"
aria-valuenow={value}
@@ -26,9 +28,9 @@
aria-valuemax="100"
on:keydown={(event) => handleKeyPress(event.key)}
>
<input type="range" class="absolute z-10 h-1 w-full" step="any" min="0" max="100" bind:value tabindex="-1" aria-hidden="true" aria-disabled="true" />
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1 rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
<input type="range" class="absolute z-10 h-1.5 w-full" step="any" min="0" max="100" bind:value tabindex="-1" aria-hidden="true" aria-disabled="true" />
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1.5 rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
</div>
<style>
@@ -38,10 +40,10 @@
opacity: 0;
}
#slider-track:hover > #slider-trail {
background-color: var(--lazuli-primary);
background-color: var(--slider-color);
}
#slider-track:focus > #slider-trail {
background-color: var(--lazuli-primary);
background-color: var(--slider-color);
}
#slider-track:hover > #slider-thumb {
opacity: 1;

View File

@@ -1,11 +1,11 @@
export class Jellyfin implements Connection {
public id: string
private userId: string
private jfUserId: string
private serverUrl: string
private accessToken: string
public readonly id: string
private readonly userId: string
private readonly jfUserId: string
private readonly serverUrl: string
private readonly accessToken: string
private readonly BASEHEADERS: Headers
private readonly authHeader: Headers
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id
@@ -14,33 +14,34 @@ export class Jellyfin implements Connection {
this.serverUrl = serverUrl
this.accessToken = accessToken
this.BASEHEADERS = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
}
// const audoSearchParams = new URLSearchParams({
// MaxStreamingBitrate: '999999999',
// Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
// TranscodingContainer: 'ts',
// TranscodingProtocol: 'hls',
// AudioCodec: 'aac',
// userId: this.jfUserId,
// })
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => {
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).toString()
const systemUrl = new URL('System/Info', this.serverUrl).toString()
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl)
const systemUrl = new URL('System/Info', this.serverUrl)
const userData: JellyfinAPI.User = await fetch(userUrl, { headers: this.BASEHEADERS }).then((response) => response.json())
const systemData: JellyfinAPI.System = await fetch(systemUrl, { headers: this.BASEHEADERS }).then((response) => response.json())
const userData: JellyfinAPI.User | undefined = await fetch(userUrl, { headers: this.authHeader })
.then((response) => response.json())
.catch(() => {
console.error(`Fetch to ${userUrl.toString()} failed`)
return undefined
})
const systemData: JellyfinAPI.System | undefined = await fetch(systemUrl, { headers: this.authHeader })
.then((response) => response.json())
.catch(() => {
console.error(`Fetch to ${systemUrl.toString()} failed`)
return undefined
})
return {
id: this.id,
userId: this.userId,
type: 'jellyfin',
serverUrl: this.serverUrl,
serverName: systemData.ServerName,
serverName: systemData?.ServerName,
jellyfinUserId: this.jfUserId,
username: userData.Name,
username: userData?.Name,
}
}
@@ -53,16 +54,11 @@ export class Jellyfin implements Connection {
limit: '10',
})
const mostPlayedSongsURL = new URL(`/Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString()
const mostPlayedSongsURL = new URL(`/Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: this.BASEHEADERS })
const mostPlayedData = await mostPlayedResponse.json()
const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json())
return Array.from(mostPlayedData.Items as JellyfinAPI.Song[], (song) => this.parseSong(song))
}
public getSongAudio = (id: string): string => {
return 'need to implement'
return Array.from(mostPlayed.Items, (song) => this.parseSong(song))
}
public search = async (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Playlist)[]> => {
@@ -72,9 +68,9 @@ export class Jellyfin implements Connection {
recursive: 'true',
})
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString()
const searchResponse = await fetch(searchURL, { headers: this.BASEHEADERS })
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL)
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const searchResponse = await fetch(searchURL, { headers: this.authHeader })
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL.toString())
const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Playlist)[] // JellyfinAPI.Artist
const parsedResults: (Song | Album | Playlist)[] = Array.from(searchResults, (result) => {
@@ -90,6 +86,21 @@ export class Jellyfin implements Connection {
return parsedResults
}
public getAudioStream = async (id: string): Promise<Response> => {
const audoSearchParams = new URLSearchParams({
MaxStreamingBitrate: '2000000',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
TranscodingContainer: 'ts',
TranscodingProtocol: 'hls',
AudioCodec: 'aac',
userId: this.jfUserId,
})
const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl)
return await fetch(audioUrl, { headers: this.authHeader })
}
private parseSong = (song: JellyfinAPI.Song): Song => {
const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).href

View File

@@ -156,29 +156,34 @@ export class YouTubeMusic implements Connection {
return recommendations
}
public getSongAudio = async (id: string): Promise<ReadableStream<Uint8Array>> => {
public getAudioStream = async (id: string): Promise<Response> => {
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`)
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
const audioResponse = await fetch(format.url)
return audioResponse.body!
return await fetch(format.url)
}
}
const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => {
const name = rowContent.title.runs[0].text
let artists: (Song | Album)['artists']
let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
for (const run of rowContent.subtitle.runs) {
if (!run.navigationEndpoint) continue
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
}
const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
return { connection, id, name, type: 'song', artists, thumbnail } satisfies Song
return { connection, id, name, type: 'song', artists, createdBy, thumbnail } satisfies Song
}
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
@@ -187,9 +192,10 @@ const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.music
case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist
return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
}
}
@@ -197,11 +203,16 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let artists: (Song | Album)['artists']
let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
for (const run of listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) {
if (!run.navigationEndpoint) continue
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
}
if (!('navigationEndpoint' in listContent)) {
@@ -212,7 +223,7 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
album = { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text }
}
return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song
return { connection, id, name, type: 'song', artists, album, createdBy, thumbnail } satisfies Song
}
const id = listContent.navigationEndpoint.browseEndpoint.browseId
@@ -221,9 +232,10 @@ const parseResponsiveListItemRenderer = (connection: string, listContent: InnerT
case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist
return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
}
}
@@ -231,23 +243,25 @@ const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.
const name = cardContent.title.runs[0].text
const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let album: Song['album'], artists: (Song | Album)['artists']
let album: Song['album'], artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
for (const run of cardContent.subtitle.runs) {
if (!run.navigationEndpoint) continue
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
} else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
}
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId
return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song
return { connection, id, name, type: 'song', artists, album, createdBy, thumbnail } satisfies Song
}
const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
@@ -256,9 +270,10 @@ const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.
case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist
return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
}
}
@@ -266,13 +281,18 @@ const refineThumbnailUrl = (urlString: string): string => {
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
const url = new URL(urlString)
if (url.origin === 'https://i.ytimg.com') {
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
} else if (url.origin === 'https://lh3.googleusercontent.com' || url.origin === 'https://yt3.googleusercontent.com' || url.origin === 'https://yt3.ggpht.com') {
return urlString.slice(0, urlString.indexOf('='))
} else {
console.error(urlString)
throw new Error('Invalid thumbnail url origin')
switch (url.origin) {
case 'https://i.ytimg.com':
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com':
return urlString.slice(0, urlString.indexOf('='))
case 'https://music.youtube.com':
return urlString
default:
console.error(urlString)
throw new Error('Invalid thumbnail url origin')
}
}
@@ -520,7 +540,7 @@ declare namespace InnerTube {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST'
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}