15 Commits

Author SHA1 Message Date
Eclypsed 529e261023 Redoing account section 2024-01-21 01:48:49 -05:00
Eclypsed 54309d06cf Layout is looking pretty slick now! 2024-01-20 16:41:53 -05:00
Eclypsed 215ff9a8d8 Did a lot of screwing around with the layout, actually starting to look decent now 2024-01-20 02:10:51 -05:00
Eclypsed b6b4e16ecd changed up the mini player some more 2024-01-18 01:58:40 -05:00
Eclypsed fe722f326a worked on the miniplayer, volume slider is fucked 2024-01-17 00:52:04 -05:00
Eclypsed a6c65ce0cf Big layout improvements, started on miniplayer 2024-01-16 02:45:27 -05:00
Eclypsed fd08867628 Lots of layout changes 2024-01-15 02:18:49 -05:00
Eclypsed 951575c284 Screwed around with the layout a bit 2024-01-13 02:36:53 -05:00
Eclypsed 8acf9b3c46 Big improvements to the media cards, scrollable card menu in the works 2024-01-12 02:44:06 -05:00
Eclypsed 838b2fa062 Fuck Fuck FUCK these stupid little media cards 2024-01-11 01:46:35 -05:00
Eclypsed 4149cf7528 Began work on fetching recommendations; created song factory 2024-01-10 02:31:49 -05:00
Eclypsed f9359ae300 Created ConnectionProfile class to encapsulate connection data for display on connection page 2024-01-09 00:46:23 -05:00
Eclypsed 4a43b06c72 Connection management now id based rather than type based 2024-01-07 14:23:22 -05:00
Eclypsed 0da467d1e0 Gonna try to start using git properly 2024-01-06 22:05:51 -05:00
Nicholas Tamassia b2790a7151 first commit 2023-10-09 17:49:46 -04:00
138 changed files with 5776 additions and 12315 deletions
-14
View File
@@ -8,17 +8,3 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
*.db
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
+1
View File
@@ -1 +1,2 @@
engine-strict=true
resolution-mode=highest
+1 -2
View File
@@ -1,3 +1,2 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
}
+34 -8
View File
@@ -1,12 +1,38 @@
# Lazuli
# create-svelte
A self hosted client to stream music from all your favorite music streaming services all in one unified interface.
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Planned Features
## Creating a project
- Connect your exisiting accounts for personalized recommendations
- Search for content across all music platforms
- Synchronize your playlist across every service
- Local downloads for offline playback
If you're seeing this, you've probably already done this step. Congrats!
![player](./static/WIP-2024-06-04.png)
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
+11
View File
@@ -0,0 +1,11 @@
Settings:
Light/Dark Mode
Album Art Mode: Toggle whether or not album art is used in various backdrops and styling
Integrations:
Stream from YT
last.fm
MusicBrainz
discogs marketplace
bandcamp
Plex
-276
View File
@@ -1,276 +0,0 @@
openapi: 3.0.0
info:
title: Lazuli API
version: 1.0.0
servers:
- url: http://host[:port]/api
paths:
/connections:
get:
summary: Returns connections by ids
tags:
- Connections
parameters:
- in: query
name: ids
schema:
type: array
items:
type: string
description: List of connection ids, comma delimited
required: true
responses:
'200':
description: A JSON array of connections
content:
application/json:
schema:
type: object
required:
- connections
properties:
connections:
type: array
items:
$ref: '#/components/schemas/ConnectionInfo'
'400':
description: Bad Request
'401':
description: Unauthorized
/users/{userId}/connections:
get:
summary: Returns all connections for a specified user
tags:
- Connections
parameters:
- in: path
name: userId
schema:
type: string
description: The user's id
required: true
responses:
'200':
description: A JSON array of connections
content:
application/json:
schema:
type: object
required:
- connections
properties:
connections:
type: array
items:
$ref: '#/components/schemas/ConnectionInfo'
'401':
description: Unauthorized
/users/{userId}/recommendations:
get:
summary: Returns recommendations for the user from all connections
tags:
- Recommendations
parameters:
- in: path
name: userId
schema:
type: string
description: The user's id
required: true
responses:
'200':
description: A JSON array of media items
content:
application/json:
schema:
type: object
required:
- recommendations
properties:
recommendations:
type: array
items:
oneOf:
- $ref: '#/components/schemas/Song'
- $ref: '#/components/schemas/Album'
- $ref: '#/components/schemas/Artist'
- $ref: '#/components/schemas/Playlist'
discriminator:
propertyName: type
'401':
description: Unauthorized
components:
schemas:
ConnectionInfo:
type: object
required:
- id
- userId
- type
properties:
id:
type: string
userId:
type: string
type:
type: string
enum:
- 'jellyfin'
- 'youtube-music'
serverUrl:
type: string
serverName:
type: string
jellyfinUserId:
type: string
username:
type: string
youtubeUserId:
type: string
profilePicture:
type: string
Song:
type: object
required:
- connection
- id
- name
- type
properties:
connection:
type: string
id:
type: string
name:
type: string
type:
type: string
enum:
- 'song'
duration:
type: number
thumbnail:
type: string
artists:
type: array
items:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
album:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
createdBy:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
releaseDate:
type: string
Album:
type: object
required:
- connection
- id
- name
- type
properties:
connection:
type: string
id:
type: string
name:
type: string
type:
type: string
enum:
- 'album'
duration:
type: number
thumbnail:
type: string
artists:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
releaseDate:
type: string
Artist:
type: object
required:
- connection
- id
- name
- type
properties:
connection:
type: string
id:
type: string
name:
type: string
type:
type: string
enum:
- 'artist'
thumbnail:
type: string
Playlist:
type: object
required:
- connection
- id
- name
- type
properties:
connection:
type: string
id:
type: string
name:
type: string
type:
type: string
enum:
- 'playlist'
createdBy:
type: object
required:
- id
- name
properties:
id:
type: string
name:
type: string
thumbnail:
type: string
+3185 -4715
View File
File diff suppressed because it is too large Load Diff
+29 -50
View File
@@ -1,52 +1,31 @@
{
"name": "lazuli-typescript",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@iconify-json/bx": "^1.1.10",
"@iconify-json/heroicons": "^1.1.22",
"@iconify-json/ic": "^1.1.17",
"@iconify-json/material-symbols": "^1.1.85",
"@iconify-json/mi": "^1.1.8",
"@iconify-json/mingcute": "^1.1.18",
"@iconify-json/ph": "^1.1.13",
"@iconify-json/solar": "^1.1.9",
"@iconify-json/teenyicons": "^1.1.9",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-tailwindcss": "^0.5.11",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.1",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"unplugin-icons": "^0.19.0",
"vite": "^5.0.3"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@types/better-sqlite3": "^7.6.10",
"@types/jsonwebtoken": "^9.0.5",
"bcrypt-ts": "^5.0.1",
"better-sqlite3": "^9.3.0",
"fast-average-color": "^9.4.0",
"googleapis": "^140.0.1",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"ky": "^1.4.0",
"zod": "^3.23.8"
}
"name": "lazuli",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.28",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-tailwindcss": "^0.5.10",
"svelte": "^4.0.5",
"tailwindcss": "^3.3.3",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.2",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.1.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"ytdl-core": "^4.11.5"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+18
View File
@@ -0,0 +1,18 @@
Big Problems:
- The stream services being utilized still need to be able to gather which songs you are listening to to provide recommendations,
so requests need to be proxied through the actual streaming service.
- Authentication for every other potential streaming service:
- YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html
- Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization
- Do I allow users to connect multiple accounts from the same service?
Little Problems:
- Video and audio need to be kept in sync, accounting for buffering and latency.
Fixed Problems:
- Fucking Jellyfin Authentication (Missing Header)
- Continuous session verification (Fixed with JWTs)
Looking for Style Guidlines?:
- URL structure: https://developers.google.com/search/docs/crawling-indexing/url-structure
- API best practicies: https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design
+15 -5
View File
@@ -1,12 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@100..900&family=Noto+Sans+JP:wght@100..900&family=Noto+Sans+KR:wght@100..900&family=Noto+Sans+SC:wght@100..900&family=Noto+Sans+TC:wght@100..900&family=Noto+Sans:wght@100..900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
img {
max-width: 100%;
max-height: 100%;
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Default scrollbar for Chrome, Safari, Edge and Opera */
@@ -33,3 +37,9 @@ img {
--jellyfin-blue: #00a4dc;
--youtube-red: #ff0000;
}
@media screen and (max-width: 768px) {
:root {
font-size: 0.7rem;
}
}
-211
View File
@@ -1,211 +0,0 @@
import 'unplugin-icons/types/svelte4.d.ts'
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: Omit<User, 'passwordHash'>
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
// General Interface Desing tips:
// Use possibly undefined `?:` for when a property is optional, meaning it could be there, or it could be not applicable
// Use possibly null `| null` for when the property is expected to be there but could possbily be explicitly empty
// Do not store data from other services in the database, only the data necessary to fetch whatever you need.
// This avoid syncronization issues. E.g. Store userId, and urlOrigin to fetch the user's name and profile picture.
// Note to self: POST vs PUT vs PATCH
// Use POST when a new resource is being created
// Use PUT when a resource is being replaced. Semantically, PUT means the entire replacement resource needs to be provided in the request
// Use PATCH when a resource is being changed or updated. Semantically, PATCH means only a partial resource needs to be provided in the request (The parts being updated/changed)
type ConnectionType = 'jellyfin' | 'youtube-music'
type User = {
id: string
username: string
passwordHash: string
}
type ConnectionInfo = {
id: string
userId: string
} & ({
type: 'jellyfin'
serverUrl: string
serverName?: string
jellyfinUserId: string
username?: string
} | {
type: 'youtube-music'
youtubeUserId: string
username?: string
profilePicture?: string
})
type MediaItemTypeMap = {
song: Song
album: Album
artist: Artist
playlist: Playlist
}
interface Connection {
public readonly id: string
/** Retireves general information about the connection */
getConnectionInfo(): Promise<ConnectionInfo>
/** Get's the user's recommendations from the corresponding service */
getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]>
/**
* @param {string} searchTerm The string of text to query
* @param {Set<'song' | 'album' | 'artist' | 'playlist'>} types A set containing any of 'song', 'album', 'artist', or 'playlist'. Specifies what media types to query
* @returns {Promise<(Song | Album | Artist | Playlist)[]>} A promise of an array of media items
*/
search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]>
/**
* @param {string} id The id of the requested song
* @param {Headers} headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range').
* @returns {Promise<Response>} A promise of response object containing the audio stream for the specified byte range
* @throws {TypeError | Error} TypeError if the id passed was invalid. Error if the connection failed to fetch the audio stream
*
* Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error.
*/
getAudioStream(id: string, headers: Headers): Promise<Response>
/**
* @param {string} id The id of an album
* @returns {Promise<Album>} A promise of the album as an Album object
*/
getAlbum(id: string): Promise<Album>
/**
* @param {string} id The id of an album
* @returns {Promise<Song[]>} A promise of the songs in the album as and array of Song objects
*/
getAlbumItems(id: string): Promise<Song[]>
/**
* @param {string} id The id of a playlist
* @returns {Promise<Playlist>} A promise of the playlist of as a Playlist object
*/
getPlaylist(id: string): Promise<Playlist>
/**
* @param {string} id The id of a playlist
* @param {number} startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
* @param {number} limit The maximum number of playlist items to return
* @returns {Promise<Song[]>} A promise of the songs in the playlist as and array of Song objects
*/
getPlaylistItems(id: string, options?: { startIndex?: number, limit?: number }): Promise<Song[]>
public readonly library: {
albums(): Promise<Album[]>
artists(): Promise<Artist[]>
playlists(): Promise<Playlist[]>
}
}
// These Schemas should only contain general info data that is necessary for data fetching purposes.
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
// Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there.
// Additionally, these types are meant to represent the "previews" of the respective media item (e.g. Recomendation, search result).
// As a result, in order to lessen the number of fetches made to external sources, only include data that is needed for these previews.
type Song = {
connection: {
id: string
type: ConnectionType
}
id: string
name: string
type: 'song'
duration: number // Seconds
thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint
releaseDate?: string // ISOString
artists?: { // Should try to order
id: string
name: string
profilePicture?: string
}[]
album?: {
id: string
name: string
}
uploader?: {
id: string
name: string
profilePicture?: string
}
isVideo: boolean
}
// Properties like duration and track count are properties of album items not the album itself
type Album = {
connection: {
id: string
type: ConnectionType
}
id: string
name: string
type: 'album'
thumbnailUrl: string
artists: { // Should try to order
id: string
name: string
profilePicture?: string
}[] | 'Various Artists'
releaseYear?: string // ####
}
// Need to figure out how to do Artists, maybe just query MusicBrainz?
type Artist = {
connection: {
id: string
type: ConnectionType
}
id: string
name: string
type: 'artist'
profilePicture?: string
}
type Playlist = { // Keep Playlist items seperate from the playlist itself. What's really nice is playlist items can just be an ordered array of Songs
connection: {
id: string
type: ConnectionType
}
id: string
name: string
type: 'playlist'
thumbnailUrl: string
createdBy?: { // Optional, in the case that a playlist is auto-generated or it's the user's playlist in which case this is unnecessary
id: string
name: string
profilePicture?: string
}
}
type Mix = {
id: string
name: string
thumbnail?: string
description?: string
trackCount: number
duration: number
}
type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> };
}
export {}
+8 -3
View File
@@ -3,11 +3,16 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@500&family=Noto+Sans+JP:wght@500&family=Noto+Sans+KR:wght@500&family=Noto+Sans+SC:wght@500&family=Noto+Sans+TC:wght@500&family=Noto+Sans:wght@500&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="hover" class="no-scrollbar m-0">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit'
import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private'
import jwt from 'jsonwebtoken'
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const nonProtectedRoutes = ['/login', '/api']
const urlpath = event.url.pathname
if (urlpath.startsWith('/api') && event.request.headers.get('apikey') !== SECRET_INTERNAL_API_KEY && event.url.searchParams.get('apikey') !== SECRET_INTERNAL_API_KEY) {
return new Response('Unauthorized', { status: 400 })
}
if (!nonProtectedRoutes.some((route) => urlpath.startsWith(route))) {
const authToken = event.cookies.get('lazuli-auth')
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY)
if (!tokenData) throw redirect(303, `/login?redirect=${urlpath}`)
event.locals.userId = tokenData.id
event.locals.username = tokenData.user
}
const response = await resolve(event)
return response
}
-53
View File
@@ -1,53 +0,0 @@
import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private'
import { userExists, connectionExists, mixExists } from '$lib/server/api-helper'
import jwt from 'jsonwebtoken'
function verifyAuthToken(event: RequestEvent) {
const authToken = event.cookies.get('lazuli-auth')
if (!authToken) return false
try {
jwt.verify(authToken, SECRET_JWT_KEY)
return true
} catch {
return false
}
}
// * Custom Handle specifically for requests made to the API endpoint. Handles authorization and any other middleware verifications
const handleAPIRequest: Handle = async ({ event, resolve }) => {
const authorized = event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event)
if (!authorized) return new Response('Unauthorized', { status: 401 })
const userId = event.params.userId
if (userId && !(await userExists(userId))) return new Response(`User ${userId} not found`, { status: 404 })
const connectionId = event.params.connectionId
if (connectionId && !(await connectionExists(connectionId))) return new Response(`Connection ${connectionId} not found`, { status: 404 })
const mixId = event.params.mixId
if (mixId && !(await mixExists(mixId))) return new Response(`Mix ${mixId} not found`, { status: 404 })
return resolve(event)
}
export const handle: Handle = async ({ event, resolve }) => {
const urlpath = event.url.pathname
if (urlpath.startsWith('/login')) return resolve(event)
if (urlpath.startsWith('/api')) return handleAPIRequest({ event, resolve })
const authToken = event.cookies.get('lazuli-auth')
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
try {
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'passwordHash'>
event.locals.user = tokenData
} catch {
throw redirect(303, `/login?redirect=${urlpath}`)
}
return resolve(event)
}
-6
View File
@@ -1,6 +0,0 @@
import ky from 'ky'
export const apiV1 = ky.create({
prefixUrl: '/api/v1/',
credentials: 'include',
})
+8
View File
@@ -0,0 +1,8 @@
<script>
export let albumImg;
</script>
<div id="album-bg-wrapper" class="w-full h-screen overflow-hidden absolute z-0">
<div class="h-full bg-cover bg-center brightness-[30%]" style="background-image: url({albumImg});" draggable="false"/>
<div class="bg-neutral-900 w-full h-full absolute top-1/2 z-10 skew-y-6"/>
</div>
+43 -57
View File
@@ -1,57 +1,43 @@
<script lang="ts">
import AutoImage from './autoImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores'
import { apiV1 } from '$lib/api-helper'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let album: Album
async function playAlbum() {
try {
const response = await apiV1.get(`connections/${album.connection.id}/album/${album.id}/items`).json<{ items: Song[] }>()
$queue.setQueue(response.items)
} catch {
$newestAlert = ['warning', 'Failed to play album']
}
}
</script>
<div class="overflow-hidden">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
<AutoImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} --object-fit="cover" --border-radius="0.25rem" --height="100%" />
</button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity">
<IconButton halo={true} on:click={playAlbum}>
<i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton>
</div>
<div id="connection-type-icon" class="absolute left-2 top-2 h-6 w-6 opacity-0 transition-opacity">
<ServiceLogo type={album.connection.type} />
</div>
</div>
<div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div>
<div class="line-clamp-2 text-neutral-400">
<ArtistList mediaItem={album} />
</div>
</div>
</div>
<style>
#thumbnail-wrapper:hover > #thumbnail {
filter: brightness(35%);
}
#thumbnail-wrapper:hover > #play-button {
opacity: 1;
}
#thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1;
}
#thumbnail {
transition: filter 150ms ease;
}
</style>
<script>
export let item;
export let cardType;
import { JellyfinUtils } from '$lib/utils'
const getAlbumCardLink = (item) => {
if (cardType === "albums") {
return `/album/${item.Id}`;
} else if (cardType === "appearances") {
return `/album/${item.AlbumId}`;
} else if (cardType === "singles") {
return `/song/${item.Id}`
} else {
throw new Error("No such cardType: " + cardType);
};
};
</script>
<a href={getAlbumCardLink(item)} style="text-decoration: none;">
<div class="image-card">
<img src="{JellyfinUtils.getImageEnpt('Primary' in item.ImageTags ? item.Id : item.AlbumId)}" alt="jacket">
<span>{item.Name}</span>
</div>
</a>
<style>
.image-card {
width: 100%;
aspect-ratio: 0.8;
background-color: rgb(32, 32, 32);
margin: 0;
}
img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
span {
color: white;
text-align: center;
}
</style>
@@ -1,47 +0,0 @@
<!--
@component
A component to easily display the artists of a track or album, or the user associated with either a Song, Album, or Playlist
object. Formatting of the text such as font size, weight, and line clamps can be specified in a wrapper element.
@param mediaItem Either a Song, Album, or Playlist object.
@param linked Boolean. If true, artists will be linked with anchor tags. Defaults to true.
-->
<script lang="ts">
export let mediaItem: Song | Album | Playlist
export let linked = true
</script>
<div class="break-words break-keep">
{#if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists === 'string'}
{mediaItem.artists}
{:else if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists !== 'string' && mediaItem.artists.length > 0}
{#each mediaItem.artists as artist, index}
{@const needsComma = index < mediaItem.artists.length - 1}
{#if linked}
<a class:needsComma class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{:else}
<span class:needsComma class="artist-name">{artist.name}</span>
{/if}
{/each}
{:else if 'uploader' in mediaItem && mediaItem.uploader}
{#if linked}
<a class="hover:underline focus:underline" href="/details/user?id={mediaItem.uploader.id}&connection={mediaItem.connection.id}">{mediaItem.uploader.name}</a>
{:else}
<span>{mediaItem.uploader.name}</span>
{/if}
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
{#if linked}
<a class="hover:underline focus:underline" href="/details/user?id={mediaItem.createdBy.id}&connection={mediaItem.connection.id}">{mediaItem.createdBy.name}</a>
{:else}
<span>{mediaItem.createdBy.name}</span>
{/if}
{/if}
</div>
<style>
.needsComma::after {
content: ',';
margin-right: 0.25em;
}
</style>
-57
View File
@@ -1,57 +0,0 @@
<!--
@component
A component to help render images in a smooth and efficient way. The url passed will be fetched via Lazuli's
remoteImage API endpoint with size parameters that are dynamically calculated base off of the image's container's
width and height. Images are lazily loaded unless 'eager' loading is specified.
@param thumbnailUrl A string of a URL that points to the desired image.
@param alt Supplementary text in the event the image fails to load.
@param loading Optional. Either the string 'lazy' or 'eager', defaults to lazy. The method by which to load the image.
-->
<script lang="ts">
import { onMount } from 'svelte'
export let thumbnailUrl: string
export let alt: string
export let loading: 'lazy' | 'eager' = 'lazy'
let currentSlot: number = 1 // 1 | 2
let imageContainer: HTMLDivElement | undefined
let slot1: HTMLImageElement | undefined
let slot2: HTMLImageElement | undefined
const SIZE_TO_PIXEL_FACTOR = 1.5 // Images will be fetched with a pixel density 1.5x the size of its container. This is a good compromise between sharpness and performance
// ? Maybe implement auto-resizing
function updateImage(newThumbnailURL: string) {
if (!(slot1 && slot2 && imageContainer)) return
const maxWidth = imageContainer.clientWidth * SIZE_TO_PIXEL_FACTOR
const maxHeight = imageContainer.clientHeight * SIZE_TO_PIXEL_FACTOR
const imageSrc = `/api/remoteImage?url=${newThumbnailURL}&${maxWidth > maxHeight ? `maxWidth=${maxWidth}` : `maxHeight=${maxHeight}`}`
currentSlot === 1 ? (slot2.src = imageSrc) : (slot1.src = imageSrc)
}
onMount(() => updateImage(thumbnailUrl))
$: updateImage(thumbnailUrl)
</script>
<div id="image-container" bind:this={imageContainer} class="grid">
<img bind:this={slot1} {alt} {loading} class:opacity-0={currentSlot === 2} class:hidden={!slot1 || slot1.src === ''} class="h-full transition-opacity duration-500" on:load={() => (currentSlot = 1)} />
<img bind:this={slot2} {alt} {loading} class:opacity-0={currentSlot === 1} class:hidden={!slot1 || slot2.src === ''} class="h-full transition-opacity duration-500" on:load={() => (currentSlot = 2)} />
</div>
<style>
#image-container {
height: var(--height);
}
img {
grid-area: 1 / 1;
object-fit: var(--object-fit);
object-position: var(--object-position);
border-radius: var(--border-radius);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script>
export let header;
export let artists;
export let year;
export let length;
import { ticksToTime } from '$lib/utils.js';
</script>
<div class="text-white flex flex-col items-baseline font-notoSans gap-2">
<span class="text-[3rem] leading-[4rem] line-clamp-2">{header}</span>
<div id="details" class="flex text-xl text-neutral-300">
{#if length}
<span>{ticksToTime(length)}</span>
{/if}
<span class="flex">
{#if artists}
{#each artists as artist}
<a class="no-underline hover:underline" href="/artist/{artist.Id}">{artist.Name}</a>
{#if artists.indexOf(artist) !== artists.length - 1}<span class="mx-2">/</span>{/if}
{/each}
{/if}
</span>
{#if year}
<span>{year}</span>
{/if}
</div>
</div>
<style>
#details > span:not(:last-child)::after {
content: "•";
margin: 0 .75rem;
}
</style>
+26 -41
View File
@@ -1,41 +1,26 @@
<script lang="ts">
import AutoImage from './autoImage.svelte'
import ArtistList from './artistList.svelte'
export let mediaItem: Song | Album | Artist | Playlist
const thumbnailUrl = 'thumbnailUrl' in mediaItem ? mediaItem.thumbnailUrl : mediaItem.profilePicture
const date = 'releaseDate' in mediaItem && mediaItem.releaseDate ? new Date(mediaItem.releaseDate).getFullYear().toString() : 'releaseYear' in mediaItem ? mediaItem.releaseYear : undefined
</script>
<div id="list-item" class="h-16 w-full">
<div class="h-full overflow-clip rounded-md">
{#if thumbnailUrl}
<AutoImage {thumbnailUrl} alt="{mediaItem.name} jacket" --object-fit="cover" />
{:else}
<div id="thumbnail-placeholder" class="grid h-full w-full place-items-center bg-lazuli-primary">
<i class="fa-solid {mediaItem.type === 'artist' ? 'fa-user' : 'fa-play'} text-2xl" />
</div>
{/if}
</div>
<div class="line-clamp-1">{mediaItem.name}</div>
<span class="line-clamp-1 text-neutral-400">
{#if mediaItem.type !== 'artist'}
<ArtistList {mediaItem} />
{/if}
</span>
<div class="justify-self-center text-neutral-400">{date ?? ''}</div>
</div>
<style>
#list-item {
display: grid;
column-gap: 1rem;
align-items: center;
grid-template-columns: 4rem 1fr 1fr 5rem;
}
#thumbnail-placeholder {
background: radial-gradient(circle, rgba(0, 0, 0, 0) 25%, rgba(35, 40, 50, 1) 100%);
}
</style>
<script>
export let item
import { JellyfinUtils, ticksToTime } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
$: jacketSrc = JellyfinUtils.getImageEnpt('Primary' in item.ImageTags ? item.Id : item.AlbumId)
const dispatch = createEventDispatcher()
const startPlaybackDispatcher = () => {
dispatch('startPlayback', {
item: item,
})
}
</script>
<button on:click={startPlaybackDispatcher} class="grid w-full grid-cols-[1em_50px_auto_3em] items-center gap-3 bg-[#1111116b] p-3 text-left font-notoSans text-neutral-300 transition-[width] duration-100 hover:w-[102%]">
<div class="justify-self-center">{item.IndexNumber}</div>
<img class="justify-self-center" src={jacketSrc} alt="" draggable="false" />
<div class="justify-items-left">
<div class="line-clamp-2">{item.Name}</div>
<div class="mt-[.15rem] text-neutral-500">{item.Artists.join(', ')}</div>
</div>
<span class="text-right">{ticksToTime(item.RunTimeTicks)}</span>
</button>
+75 -64
View File
@@ -1,88 +1,99 @@
<script lang="ts">
export let mediaItem: Song | Album | Artist | Playlist
<script>
export let mediaData
import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation'
import { queue } from '$lib/stores'
import Services from '$lib/services.json'
import IconButton from '$lib/components/utility/iconButton.svelte'
import { backgroundImage, currentlyPlaying } from '$lib/utils/stores.js'
let image: HTMLImageElement, captionText: HTMLDivElement
async function setQueueItems(mediaItem: Album | Playlist) {
const itemsResponse = await fetch(`/api/connections/${mediaItem.connection.id}/${mediaItem.type}/${mediaItem.id}/items`, {
credentials: 'include',
}).then((response) => response.json() as Promise<{ items: Song[] }>)
const items = itemsResponse.items
$queue.setQueue(items)
const iconClasses = {
song: 'fa-solid fa-music',
album: 'fa-solid fa-compact-disc',
artist: 'fa-solid fa-user',
playlist: 'fa-solid fa-forward-fast',
}
let card, cardGlare
const rotateCard = (event) => {
const cardRect = card.getBoundingClientRect()
const x = (2 * (event.x - cardRect.left)) / cardRect.width - 1 // These are simplified calculations to find the x-y coords relative to the center of the card
const y = (2 * (cardRect.top - event.y)) / cardRect.height + 1
const angle = Math.atan(x / y) // You'd think it should be y / x but it's actually the inverse
const distanceFromCorner = Math.sqrt((x - 1) ** 2 + (y - 1) ** 2) // This is a cool little trick, the -1 on the x an y coordinate is effective the same as saying "make the origin of the glare [1, 1]"
cardGlare.style.backgroundImage = `linear-gradient(${angle}rad, transparent ${distanceFromCorner * 50 + 50}%, rgba(255, 255, 255, 0.1) ${distanceFromCorner * 50 + 60}%, transparent 100%)`
card.style.transform = `rotateX(${y * 10}deg) rotateY(${x * 10}deg)`
}
// TEST IMAGES --> https://f4.bcbits.com/img/a2436961975_10.jpg | {mediaData.image} | https://i.ytimg.com/vi/yvFgNP9iqd4/maxresdefault.jpg
</script>
<div id="card-wrapper" class="flex-shrink-0">
<button id="thumbnail" class="relative h-52 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}&connection=${mediaItem.connection.id}`)}>
{#if 'thumbnailUrl' in mediaItem || 'profilePicture' in mediaItem}
<img
bind:this={image}
id="card-image"
on:load={() => (captionText.style.width = `${image.width}px`)}
class="h-full rounded transition-all"
src="/api/remoteImage?url={'thumbnailUrl' in mediaItem ? mediaItem.thumbnailUrl : mediaItem.profilePicture}"
alt="{mediaItem.name} thumbnail"
/>
<a id="card-wrapper" on:mousedown|preventDefault on:mousemove={(event) => rotateCard(event)} on:mouseleave={() => (card.style.transform = null)} href="/details?id={mediaData.id}&service={mediaData.connectionId}">
<div bind:this={card} id="card" class="relative h-56 transition-all duration-200 ease-out">
{#if mediaData.image}
<img id="card-image" class="h-full max-w-none rounded-lg transition-all" src={mediaData.image} alt="{mediaData.name} thumbnail" />
{:else}
<div id="card-image" class="grid aspect-square h-full place-items-center rounded-lg bg-lazuli-primary transition-all">
<i class="fa-solid fa-compact-disc text-7xl" />
</div>
{/if}
<span id="play-button" class="absolute left-1/2 top-1/2 h-12 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-200 ease-out">
<IconButton
halo={true}
on:click={() => {
switch (mediaItem.type) {
case 'song':
$queue.setQueue([mediaItem])
break
case 'album':
case 'playlist':
setQueueItems(mediaItem)
break
}
}}
>
<i slot="icon" class="fa-solid fa-play text-xl" />
</IconButton>
</span>
</button>
<div bind:this={captionText} class="w-56 p-1">
<div class="mb-0.5 line-clamp-2 text-wrap text-sm" title={mediaItem.name}>{mediaItem.name}</div>
<div class="leading-2 line-clamp-2 flex flex-wrap text-neutral-400" style="font-size: 0;">
{#if 'artists' in mediaItem && mediaItem.artists && mediaItem.artists.length > 0}
{#if mediaItem.artists === 'Various Artists'}
<span class="text-sm">Various Artists</span>
{:else}
{#each mediaItem.artists as artist, index}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{#if index < mediaItem.artists.length - 1}
<div bind:this={cardGlare} id="card-glare" class="absolute top-0 grid h-full w-full place-items-center rounded-lg opacity-0 transition-opacity duration-200 ease-out">
<span class="relative h-12">
<IconButton
on:click={() => {
$currentlyPlaying = mediaData
$backgroundImage = mediaData.image
}}
>
<i slot="icon" class="fa-solid fa-play text-xl" />
</IconButton>
</span>
</div>
<div class="absolute -bottom-3 w-full px-2.5 text-sm">
<div class="overflow-hidden text-ellipsis whitespace-nowrap" title={mediaData.name}>{mediaData.name}</div>
<div class="flex w-full items-center gap-1.5 overflow-hidden text-neutral-400">
<span class="overflow-hidden text-ellipsis" style="font-size: 0; line-height: 0;">
<!-- the font size of zero is to remove the stupid little gaps between the spans -->
{#each mediaData.artists as artist}
{@const listIndex = mediaData.artists.indexOf(artist)}
<a class="text-sm hover:underline" href="/artist?id={artist.id}&service={mediaData.connectionId}">{artist.name}</a>
{#if listIndex === mediaData.artists.length - 2}
<span class="mx-0.5 text-sm">&</span>
{:else if listIndex < mediaData.artists.length - 2}
<span class="mr-0.5 text-sm">,</span>
{/if}
{/each}
</span>
{#if mediaData.mediaType}
<span>&bull;</span>
<i class="{iconClasses[mediaData.mediaType]} text-xs" style="color: var({Services[mediaData.serviceType].primaryColor});" />
{/if}
{:else if 'uploader' in mediaItem && mediaItem.uploader}
<a class="text-sm hover:underline focus:underline" href="/details/user?id={mediaItem.uploader.id}&connection={mediaItem.connection.id}">{mediaItem.uploader.name}</a>
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
<a class="text-sm hover:underline focus:underline" href="/details/user?id={mediaItem.createdBy.id}&connection={mediaItem.connection.id}">{mediaItem.createdBy.name}</a>
{/if}
</div>
</div>
</div>
</div>
</a>
<style>
#thumbnail:hover {
scale: 1.05;
#card-wrapper {
perspective: 1000px;
}
#thumbnail:hover #card-image {
#card-wrapper:focus-within #card-image {
filter: brightness(50%);
}
#thumbnail:hover #play-button {
#card-wrapper:focus-within #card-glare {
opacity: 1;
}
#card:hover {
scale: 1.05;
}
#card:hover > #card-image {
filter: brightness(50%);
}
#card:hover > #card-glare {
opacity: 1;
}
#card-image {
mask-image: linear-gradient(to bottom, black 50%, transparent 95%);
}
</style>
@@ -0,0 +1,56 @@
<script>
export let type
import { createEventDispatcher } from 'svelte'
import { onMount } from 'svelte'
const dispatch = createEventDispatcher()
const eventTypesIcons = {
playing: 'fa-solid fa-pause', // Reversed - If song is playing, should be pause icon
paused: 'fa-solid fa-play', // Reversed - If song is paused, should be play icon
stop: 'fa-solid fa-stop',
nexttrack: 'fa-solid fa-forward-step',
previoustrack: 'fa-solid fa-backward-step',
}
$: icon = eventTypesIcons[type] // Reacitive for when the type switches between playing/paused
onMount(() => {
if (!(type in eventTypesIcons)) {
throw new Error(`${type} type is not a valid mediaControl type`)
}
})
const mediaControlEvent = () => {
dispatch('mediaControlEvent', {
eventType: type,
})
}
</script>
<button id="button" on:click={mediaControlEvent} class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="{icon} text-3xl text-white" />
</button>
<style>
#button:hover::before {
background-color: rgba(0, 164, 220, 0.2);
height: 100%;
}
#button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #00a4dc;
border-radius: 100%;
height: 80%;
aspect-ratio: 1;
transition-property: height background-color;
transition-duration: 80ms;
transition-timing-function: linear;
z-index: -1;
}
</style>
+182 -215
View File
@@ -1,215 +1,182 @@
<script lang="ts">
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte'
import AutoImage from '$lib/components/media/autoImage.svelte'
import PhQueueBold from '~icons/ph/queue-bold'
import PhShuffleBold from '~icons/ph/shuffle-bold'
import PhRepeatBold from '~icons/ph/repeat-bold'
import BxVolumeFull from '~icons/bx/volume-full'
import BxVolumeLow from '~icons/bx/volume-low'
import BxVolume from '~icons/bx/volume'
import MiExpand from '~icons/mi/expand'
import { onMount, createEventDispatcher } from 'svelte'
import { slide, fade } from 'svelte/transition'
const dispatch = createEventDispatcher()
export let mediaItem: Song,
shuffled: boolean,
mediaSession: MediaSession | null = null
let loop = false,
favorite = false
const MAX_VOLUME = 0.5
let volume: number, paused: boolean, waiting: boolean
onMount(() => {
volume = getStoredVolume()
if (mediaSession) {
mediaSession.setActionHandler('play', () => (paused = false))
mediaSession.setActionHandler('pause', () => (paused = true))
mediaSession.setActionHandler('stop', () => dispatch('stop'))
mediaSession.setActionHandler('nexttrack', () => dispatch('next'))
mediaSession.setActionHandler('previoustrack', () => dispatch('previous'))
}
})
function getStoredVolume(): number {
const storedVolume = Number.parseFloat(localStorage.getItem('volume') ?? '-1')
if (storedVolume >= 0 && storedVolume <= MAX_VOLUME) return storedVolume
const defaultVolume = MAX_VOLUME / 2
localStorage.setItem('volume', defaultVolume.toString())
return defaultVolume
}
$: if (mediaSession) updateMediaSession(mediaItem, mediaSession)
function updateMediaSession(media: Song, mediaSession: MediaSession) {
const mediaImage = (size: number): MediaImage => ({ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=${size}`, sizes: `${size}x${size}` })
const title = media.name
const artist = media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name
const album = media.album?.name
const artwork: MediaImage[] = [mediaImage(96), mediaImage(128), mediaImage(192), mediaImage(256), mediaImage(384), mediaImage(512)]
mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork })
}
$: paused && mediaSession ? (mediaSession.playbackState = 'paused') : mediaSession ? (mediaSession.playbackState = 'playing') : null
function formatTime(seconds: number) {
seconds = Math.round(seconds)
const hours = Math.floor(seconds / 3600)
seconds -= hours * 3600
const minutes = Math.floor(seconds / 60)
seconds -= minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
let seeking = false,
currentTime: number = 0,
duration: number = 0
let audioElement: HTMLAudioElement, currentTimestamp: string, durationTimestamp: string, progressBarValue: number, progressBar: Slider
$: currentTimestamp = formatTime(seeking ? progressBarValue : currentTime)
$: durationTimestamp = formatTime(duration)
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
let playerWidth: number
</script>
<div bind:clientWidth={playerWidth} transition:slide id="player" class="flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2.5 text-neutral-400">
<div id="details" class="flex h-full items-center gap-3 overflow-clip py-2.5" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 16rem">
<div class="relative aspect-square h-full">
<AutoImage thumbnailUrl={mediaItem.thumbnailUrl} alt="{mediaItem.name} jacket" loading="eager" --object-fit="cover" --height="100%" --border-radius="0.25rem" />
<div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
</IconButton>
</div>
</div>
<div class="flex flex-col justify-center gap-1">
<ScrollingText>
<span slot="text" title={mediaItem.name} class="line-clamp-1 text-sm font-medium text-neutral-200">{mediaItem.name}</span>
</ScrollingText>
<div class="line-clamp-1 text-xs">
<ArtistList {mediaItem} />
</div>
</div>
<div class="h-8">
<IconButton --color="#ec4899" toggled={favorite} on:click={() => (favorite = !favorite)}>
<i slot="icon" class={favorite ? 'fa-solid fa-heart' : 'fa-regular fa-heart'} />
</IconButton>
</div>
<span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span>
</div>
{#if playerWidth > 700}
<!-- Change the flex-grow value to adjust the difference in rate of expansion between the details and controls -->
<div id="controls" class="flex h-full items-center gap-1 py-4 pr-8 text-neutral-200" style="backgrounds: linear-gradient(to right, green, yellow); flex-grow: 10;">
<IconButton on:click={() => dispatch('previous')}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => dispatch('stop')}>
<i slot="icon" class="fa-solid fa-stop text-xl" />
</IconButton>
<IconButton on:click={() => dispatch('next')}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
</IconButton>
{#if playerWidth > 800}
<div class="flex flex-grow items-center justify-items-center gap-3 text-xs font-light">
<span>{currentTimestamp}</span>
<Slider
bind:this={progressBar}
bind:value={progressBarValue}
max={duration}
on:seeking={() => (seeking = true)}
on:seeked={() => {
currentTime = progressBarValue
seeking = false
}}
/>
<span>{durationTimestamp}</span>
</div>
{:else}
<span class="whitespace-nowrap text-xs font-light text-neutral-400">{currentTimestamp} / {durationTimestamp}</span>
{/if}
</div>
{/if}
{#if playerWidth > 450}
<div id="tools" class="flex h-full justify-end gap-0.5 py-6" style="backgrounds: linear-gradient(to right, purple, orange);">
{#if playerWidth > 1100}
<IconButton --hover-color="#e5e5e5" toggled={shuffled} on:click={() => dispatch('toggleShuffle')}>
<PhShuffleBold slot="icon" />
</IconButton>
<IconButton --hover-color="#e5e5e5" toggled={loop} on:click={() => (loop = !loop)}>
<PhRepeatBold slot="icon" />
</IconButton>
<IconButton --hover-color="#e5e5e5">
<PhQueueBold slot="icon" />
</IconButton>
<div class="flex h-full items-center gap-1">
<IconButton --hover-color="#e5e5e5" on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}>
<span slot="icon" class="relative grid place-items-center">
{#if volume > MAX_VOLUME / 2}
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolumeFull /></span>
{:else if volume > 0}
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolumeLow /></span>
{:else}
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolume /></span>
{/if}
</span>
</IconButton>
<div class="mr-2 w-20">
<Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
</div>
</div>
<IconButton --hover-color="#e5e5e5">
<MiExpand slot="icon" />
</IconButton>
{/if}
<IconButton --hover-color="#e5e5e5">
<i slot="icon" class="fa-solid fa-ellipsis-vertical" />
</IconButton>
</div>
{/if}
<audio
{loop}
autoplay
src="/api/v1/audio?connection={mediaItem.connection.id}&id={mediaItem.id}"
bind:paused
bind:volume
bind:duration
bind:currentTime
bind:this={audioElement}
on:ended={() => dispatch('next')}
on:waiting={() => (waiting = true)}
on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
/>
</div>
<style>
#player {
border-radius: var(--border-radius);
transition: border-radius 150ms linear;
-webkit-box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
-moz-box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
}
</style>
<script>
export let currentlyPlaying
export let playlistItems
import { JellyfinUtils } from '$lib/utils'
import { onMount, createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
import ListItem from '$lib/listItem.svelte'
import MediaControl from '$lib/mediaControl.svelte'
const dispatch = createEventDispatcher()
$: currentlyPlayingImageId = 'Primary' in currentlyPlaying.ImageTags ? currentlyPlaying.Id : currentlyPlaying.AlbumId
$: currentlyPlayingImage = JellyfinUtils.getImageEnpt(currentlyPlayingImageId)
$: audioEndpoint = JellyfinUtils.getAudioEnpt(currentlyPlaying.Id, 'default')
let audio
let audioVolume = 0.1
let progressBar
let playingState = 'paused'
onMount(() => {
audio = document.getElementById('audio')
audio.volume = audioVolume
progressBar = document.getElementById('progress-bar')
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => playSong())
navigator.mediaSession.setActionHandler('pause', () => pauseSong())
navigator.mediaSession.setActionHandler('stop', () => closeMediaPlayer())
navigator.mediaSession.setActionHandler('nexttrack', () => playNext())
navigator.mediaSession.setActionHandler('previoustrack', () => playPrevious())
}
})
$: updateAudioSrc(audioEndpoint)
const updateAudioSrc = (newAudioEndpoint) => {
if (!audio) {
return onMount(() => {
audio.src = newAudioEndpoint
playSong()
})
}
audio.src = newAudioEndpoint
playSong()
}
$: updateMediaSession(currentlyPlaying)
const updateMediaSession = (media) => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: media.Name,
artist: media.Artists.join(' / '),
album: media.Album,
artwork: [
{
src: currentlyPlayingImage + '&width=96&height=96',
sizes: '96x96',
type: 'image/png',
},
{
src: currentlyPlayingImage + '&width=128&height=128',
sizes: '128x128',
type: 'image/png',
},
{
src: currentlyPlayingImage + '&width=192&height=192',
sizes: '192x192',
type: 'image/png',
},
{
src: currentlyPlayingImage + '&width=256&height=256',
sizes: '256x256',
type: 'image/png',
},
{
src: currentlyPlayingImage + '&width=384&height=384',
sizes: '384x384',
type: 'image/png',
},
{
src: currentlyPlayingImage + '&width=512&height=512',
sizes: '512x512',
type: 'image/png',
},
],
})
}
}
const playSong = () => {
audio.play()
playingState = 'playing'
}
const pauseSong = () => {
audio.pause()
playingState = 'paused'
}
const playNext = () => {
let nextSong = playlistItems[playlistItems.indexOf(currentlyPlaying) + 1]
if (nextSong) {
dispatch('startPlayback', {
item: nextSong,
})
}
}
const playPrevious = () => {
let previousSong = playlistItems[playlistItems.indexOf(currentlyPlaying) - 1]
if (previousSong) {
dispatch('startPlayback', {
item: previousSong,
})
}
}
const updateProgressBar = (event) => {
if (event.target.currentTime) {
let currentPercentage = event.target.currentTime / event.target.duration
if (document.activeElement !== progressBar) {
progressBar.value = currentPercentage
}
}
}
const updateAudioTime = () => {
let audioTimeStamp = audio.duration * progressBar.value
audio.currentTime = audioTimeStamp
}
const closeMediaPlayer = () => {
dispatch('closeMediaPlayer')
}
</script>
<div id="layout" class="grid h-full grid-cols-1 grid-rows-[3fr_2fr] lg:grid-cols-[2fr_1fr] lg:grid-rows-1">
<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 currentlyPlaying}
<img in:fade src={currentlyPlayingImage} alt="" class="absolute h-full w-full object-cover brightness-[70%]" />
{/key}
</div>
<div class="absolute grid h-full w-full grid-rows-[auto_8rem_3rem_6rem] justify-items-center p-8">
{#key currentlyPlaying}
<img in:fade src={currentlyPlayingImage} 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 currentlyPlaying}
<span class="text-xl text-neutral-500">{currentlyPlaying.Album}</span>
<span class="text-3xl text-neutral-300">{currentlyPlaying.Name}</span>
<span class="text-xl text-neutral-500">{currentlyPlaying.Artists.join(' / ')}</span>
{/key}
</div>
<input id="progress-bar" on:mouseup={updateAudioTime} 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">
<MediaControl type={'previoustrack'} on:mediaControlEvent={() => playPrevious()} />
<MediaControl type={playingState} on:mediaControlEvent={(event) => (event.detail.eventType === 'playing' ? pauseSong() : playSong())} />
<MediaControl type={'stop'} on:mediaControlEvent={() => closeMediaPlayer()} />
<MediaControl type={'nexttrack'} on:mediaControlEvent={() => playNext()} />
</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">
{#each playlistItems as item}
{#if item == currentlyPlaying}
<div class="flex w-full bg-neutral-500">
<ListItem {item} />
</div>
{:else}
<div class="flex w-full hover:bg-neutral-500">
<ListItem {item} on:startPlayback />
</div>
{/if}
{/each}
</div>
<audio id="audio" on:ended={playNext} on:timeupdate={updateProgressBar} crossorigin="anonymous" class="hidden" />
</div>
@@ -1,333 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, slide, fly } from 'svelte/transition'
import { queue } from '$lib/stores'
import Services from '$lib/services.json'
// import { FastAverageColor } from 'fast-average-color'
import AutoImage from './autoImage.svelte'
import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
// dedicated sidebar like in spotify.
$: currentlyPlaying = $queue.current
let expanded = false
let paused = true,
loop = false
$: shuffled = $queue.isShuffled
const maxVolume = 0.5
let volume: number
let waiting: boolean
function formatTime(seconds: number) {
seconds = Math.round(seconds)
const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
$: updateMediaSession(currentlyPlaying)
function updateMediaSession(media: Song | null) {
if (!('mediaSession' in navigator)) return
if (!media) {
navigator.mediaSession.metadata = null
return
}
navigator.mediaSession.metadata = new MediaMetadata({
title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
album: media.album?.name,
artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
],
})
}
onMount(() => {
const storedVolume = Number(localStorage.getItem('volume'))
if (storedVolume >= 0 && storedVolume <= maxVolume) {
volume = storedVolume
} else {
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = maxVolume / 2
}
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
let duration: number = 0
let currentTimeTimestamp: HTMLSpanElement
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)
let audioElement: HTMLAudioElement
</script>
{#if currentlyPlaying}
<div id="player-wrapper" transition:slide class="{expanded ? 'h-full' : 'h-20'} absolute bottom-0 z-40 w-full overflow-clip bg-neutral-925 transition-all ease-in-out" style="transition-duration: 400ms;">
{#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
<section class="flex w-96 min-w-64 gap-2">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl p-2">
<AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="{currentlyPlaying.name} jacket" --object-fit="cover" />
</div>
<section class="flex flex-grow flex-col justify-center gap-1">
<div class="h-6">
<ScrollingText>
<div slot="text" class="line-clamp-1 font-medium">{currentlyPlaying.name}</div>
</ScrollingText>
</div>
<div class="line-clamp-1 text-xs font-extralight">
<ArtistList mediaItem={currentlyPlaying} />
</div>
</section>
</section>
<section class="flex flex-grow items-center gap-1 py-4">
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop text-xl" />
</IconButton>
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
</IconButton>
<div class="flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
<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
}}
/>
<span bind:this={durationTimestamp} class="w-16 text-left" />
</div>
</section>
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => (expanded = true)}>
<i slot="icon" class="fa-solid fa-chevron-up" />
</IconButton>
</section>
</main>
{:else}
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
<div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
<AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="" --object-fit="cover" />
</div>
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
<div class="grid grid-cols-[2fr_1fr]">
<div class="flex h-14 flex-row items-center gap-5">
<ServiceLogo type={currentlyPlaying.connection.type} />
<div>
<h1 class="text-neutral-400">STREAMING FROM</h1>
<strong class="text-2xl text-neutral-300">{Services[currentlyPlaying.connection.type].displayName}</strong>
</div>
</div>
<section>
{#if $queue.upNext}
{@const next = $queue.upNext}
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 overflow-clip rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
<div class="aspect-square h-full">
<AutoImage thumbnailUrl={next.thumbnailUrl} alt="{next.name} jacket" --object-fit="cover" />
</div>
<div>
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
<div class="line-clamp-1 text-sm font-light text-neutral-300">
<ArtistList mediaItem={next} linked={false} />
</div>
</div>
</div>
{/if}
</section>
</div>
<AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="{currentlyPlaying.name} jacket" --object-fit="contain" --object-position="left" />
</section>
<section class="self-center px-16">
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={expandedCurrentTimeTimestamp} />
<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} />
</div>
<div id="expanded-controls">
<div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
<div class="h-10">
<ScrollingText>
<strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
</ScrollingText>
</div>
<div class="flex gap-3 text-lg font-medium text-neutral-300">
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
<ArtistList mediaItem={currentlyPlaying} />
{/if}
{#if currentlyPlaying.album}
<strong>&bullet;</strong>
<a
on:click={() => (expanded = false)}
class="line-clamp-1 hover:underline focus:underline"
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
>
{/if}
</div>
</div>
<div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full bg-white text-black">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop" />
</IconButton>
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
</div>
<section class="flex h-min items-center justify-end gap-2 text-xl">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<IconButton on:click={() => (expanded = false)}>
<i slot="icon" class="fa-solid fa-chevron-down" />
</IconButton>
</section>
</div>
</section>
</main>
{/if}
<audio
bind:this={audioElement}
autoplay
bind:paused
bind:volume
bind:currentTime
bind:duration
on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)}
on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
{loop}
/>
</div>
{/if}
<style>
#expanded-player {
display: grid;
grid-template-rows: 4fr 1fr;
}
#expanded-controls {
display: grid;
gap: 3rem;
align-items: center;
grid-template-columns: 1fr min-content 1fr !important;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<script>
export let displayMode
import IconButton from '$lib/components/utility/iconButton.svelte'
import VolumeSlider from '$lib/components/utility/volumeSlider.svelte'
import Slider from '$lib/components/utility/slider.svelte'
import { formatDuration } from '$lib/utils/utils.js'
import { currentlyPlaying } from '$lib/utils/stores.js'
import { slide } from 'svelte/transition'
$: song = $currentlyPlaying
let volume
let songLiked = false,
songPlaying = false,
fullplayerOpen = false
</script>
{#if song && displayMode === 'horizontal'}
<div class="w-full p-4">
<div class="grid w-full grid-cols-3 grid-rows-1 items-center gap-10 overflow-hidden rounded-xl bg-neutral-950 px-6 text-lg" style="height: 80px;" transition:slide={{ axis: 'y' }}>
<section class="flex h-full w-full items-center justify-start gap-4 py-2.5 text-sm">
<img class="h-full rounded-lg object-contain" src={song.image} alt="{song.name} thumbnail" />
<div class="flex h-full flex-col justify-center gap-1 overflow-hidden">
<span class="overflow-hidden text-ellipsis" title={song.name}>{song.name}</span>
<span class="overflow-hidden text-ellipsis text-neutral-400" style="font-size: 0; line-height: 0;">
{#each song.artists as artist}
{@const listIndex = song.artists.indexOf(artist)}
<a class="text-xs hover:underline" href="/artist?id={artist.id}&service={song.connectionId}">{artist.name}</a>
{#if listIndex === song.artists.length - 2}
<span class="mx-0.5 text-xs">&</span>
{:else if listIndex < song.artists.length - 2}
<span class="mr-0.5 text-xs">,</span>
{/if}
{/each}
</span>
</div>
<button class="grid aspect-square h-6 place-items-center text-lg transition-all" on:click={() => (songLiked = !songLiked)}>
{#if songLiked}
<i class="fa-solid fa-heart text-fuchsia-400" />
{:else}
<i class="fa-regular fa-heart text-neutral-400 hover:text-white" />
{/if}
</button>
</section>
<section class="flex h-full w-full flex-col justify-center gap-1 justify-self-center">
<div class="flex h-6 items-center justify-center gap-4">
<IconButton halo={false}>
<i slot="icon" class="fa-solid fa-backward-step" />
</IconButton>
<IconButton halo={false} on:click={() => (songPlaying = !songPlaying)}>
<i slot="icon" class="fa-solid {songPlaying ? 'fa-pause' : 'fa-play'}" />
</IconButton>
<IconButton halo={false} on:click={() => ($currentlyPlaying = null)}>
<i slot="icon" class="fa-solid fa-stop" />
</IconButton>
<IconButton halo={false}>
<i slot="icon" class="fa-solid fa-forward-step" />
</IconButton>
</div>
<div class="flex items-center gap-2 text-sm text-neutral-400">
<div class="whitespace-nowrap">0:00</div>
<Slider />
<div class="whitespace-nowrap">{formatDuration(song.duration)}</div>
</div>
</section>
<section class="flex h-full items-center justify-end gap-1 py-5">
<VolumeSlider bind:volume />
<IconButton halo={false}>
<i slot="icon" class="fa-solid fa-shuffle" />
</IconButton>
<IconButton halo={false}>
<i slot="icon" class="fa-solid fa-repeat" />
</IconButton>
<IconButton halo={false}>
<i slot="icon" class="fa-solid fa-ellipsis-vertical" />
</IconButton>
<IconButton halo={false} on:click={() => (fullplayerOpen = !fullplayerOpen)}>
<i slot="icon" class="fa-solid {fullplayerOpen ? 'fa-caret-down' : 'fa-caret-up'}" />
</IconButton>
</section>
</div>
</div>
{:else if song && displayMode === 'vertical'}
<div class="w-full p-2" style="height: 80px;" transition:slide={{ axis: 'y' }}>
<div class="flex h-full justify-between rounded-xl bg-neutral-950 p-2.5">
<section class="flex gap-4">
<img class="h-full rounded-xl object-contain" src={song.image} alt="{song.name} thumbnail" />
<div class="flex h-full flex-col justify-center gap-1 overflow-hidden text-lg">
<span class="overflow-hidden text-ellipsis" title={song.name}>{song.name}</span>
<span class="overflow-hidden text-ellipsis text-neutral-400">{Array.from(song.artists, (artist) => artist.name).join(', ')}</span>
</div>
</section>
<section class="flex h-full justify-end gap-6 p-4 text-3xl">
<button class="grid aspect-square h-full place-items-center transition-all" on:click={() => (songLiked = !songLiked)}>
{#if songLiked}
<i class="fa-solid fa-heart text-fuchsia-400" />
{:else}
<i class="fa-regular fa-heart text-neutral-400 hover:text-white" />
{/if}
</button>
<IconButton halo={false} on:click={() => (songPlaying = !songPlaying)}>
<i slot="icon" class="fa-solid {songPlaying ? 'fa-pause' : 'fa-play'}" />
</IconButton>
</section>
</div>
</div>
{/if}
@@ -1,62 +0,0 @@
<script lang="ts">
import AutoImage from './autoImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores'
import { apiV1 } from '$lib/api-helper'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let playlist: Playlist
async function playPlaylist() {
try {
const initialResponse = await apiV1.get(`connections/${playlist.connection.id}/playlist/${playlist.id}/items?startIndex=0&limit=50`).json<{ items: Song[] }>()
$queue.setQueue(initialResponse.items)
if (initialResponse.items.length < 50) return
const remainderResponse = await apiV1.get(`connections/${playlist.connection.id}/playlist/${playlist.id}/items?startIndex=50`).json<{ items: Song[] }>()
$queue.enqueue(...remainderResponse.items)
} catch {
$newestAlert = ['warning', 'Failed to play playlist']
}
}
</script>
<div class="overflow-hidden">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/playlist?id=${playlist.id}&connection=${playlist.connection.id}`)}>
<AutoImage thumbnailUrl={playlist.thumbnailUrl} alt="{playlist.name} jacket" --object-fit="cover" --height="100%" --border-radius="0.25rem" />
</button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity">
<IconButton halo={true} on:click={playPlaylist}>
<i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton>
</div>
<div id="connection-type-icon" class="absolute left-2 top-2 h-6 w-6 opacity-0 transition-opacity">
<ServiceLogo type={playlist.connection.type} />
</div>
</div>
<div class="py-2 text-center text-sm">
<div class="line-clamp-2">{playlist.name}</div>
<div class="line-clamp-2 text-neutral-400">
<ArtistList mediaItem={playlist} />
</div>
</div>
</div>
<style>
#thumbnail-wrapper:hover > #thumbnail {
filter: brightness(35%);
}
#thumbnail-wrapper:hover > #play-button {
opacity: 1;
}
#thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1;
}
#thumbnail {
transition: filter 150ms ease;
}
</style>
@@ -1,12 +1,12 @@
<script lang="ts">
export let header: string
export let cardDataList: (Song | Album | Artist | Playlist)[]
<script>
export let header = null
export let cardDataList
import MediaCard from '$lib/components/media/mediaCard.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import Card from '$lib/components/media/mediaCard.svelte'
import IconButton from '$lib/components/utility/iconButton.svelte'
let scrollable: HTMLDivElement,
scrollableWidth: number,
let scrollable,
scrollableWidth,
isScrollable = false,
scrollpos = 0
@@ -16,7 +16,9 @@
<section>
<div class="flex h-10 items-center justify-between">
<h1 class="text-4xl"><strong>{header}</strong></h1>
{#if header}
<h1 class="text-4xl"><strong>{header}</strong></h1>
{/if}
<div class="flex h-full gap-2">
<IconButton disabled={scrollpos < 0.01 || !isScrollable} on:click={() => (scrollable.scrollLeft -= scrollable.clientWidth)}>
<i slot="icon" class="fa-solid fa-angle-left" />
@@ -30,10 +32,10 @@
bind:this={scrollable}
bind:clientWidth={scrollableWidth}
on:scroll={() => (scrollpos = scrollable.scrollLeft / (scrollable.scrollWidth - scrollable.clientWidth))}
class="no-scrollbar flex gap-6 overflow-y-hidden overflow-x-scroll scroll-smooth p-4"
class="no-scrollbar flex gap-6 overflow-y-hidden overflow-x-scroll scroll-smooth py-4"
>
{#each cardDataList as mediaItem}
<MediaCard {mediaItem} />
{#each cardDataList as cardData}
<Card mediaData={cardData} />
{/each}
</div>
</section>
-47
View File
@@ -1,47 +0,0 @@
<!-- Credit to https://cssloaders.github.io/ -->
<script lang="ts">
export let size = 5
</script>
<span id="loader" style="height: {size}rem; width: {size}rem;" class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
<style>
#loader:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: inherit;
height: inherit;
border-radius: 50%;
animation: 1s spin linear infinite;
}
@keyframes spin {
0%,
100% {
box-shadow: 0.2rem 0;
}
12% {
box-shadow: 0.2rem 0.2rem;
}
25% {
box-shadow: 0 0.2rem;
}
37% {
box-shadow: -0.2rem 0.2rem;
}
50% {
box-shadow: -0.2rem 0;
}
62% {
box-shadow: -0.2rem -0.2rem;
}
75% {
box-shadow: 0 -0.2rem;
}
87% {
box-shadow: 0.2rem -0.2rem;
}
}
</style>
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let color: string
export let name: string
export let id: string
export let disabled: boolean = false
</script>
<button style="--mix-color: {color};" {disabled} class="block w-full overflow-hidden text-ellipsis py-3 text-left" on:click={() => goto(`/mixes/${id}`)}>
<span class:disabled class="relative text-nowrap py-1 pl-6">{name}</span>
</button>
<style>
span:hover {
color: var(--mix-color);
}
span.disabled {
color: var(--mix-color);
}
span::before {
content: '•';
margin-right: 0.75rem;
font-weight: 900;
color: var(--mix-color);
}
span::after {
content: '';
width: 100%;
height: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
background: linear-gradient(to right, var(--mix-color) 2px, color-mix(in srgb, var(--mix-color), transparent) 2px, transparent 20%);
transition: height 100ms linear;
}
span.disabled::after {
height: 100%;
}
</style>
-39
View File
@@ -1,39 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let icon: string
export let label: string
export let redirect: string
export let disabled: boolean = false
</script>
<button {disabled} class="block w-full overflow-hidden text-ellipsis py-2 text-left" on:click={() => goto(redirect)}>
<span class:disabled class="relative text-nowrap py-1 pl-6 text-neutral-300">
<i class="{icon} mr-1.5 h-5 w-5" />
{label}
</span>
</button>
<style>
span:hover {
color: var(--lazuli-primary);
}
span.disabled {
color: var(--lazuli-primary);
}
span::before {
content: '';
width: 100%;
height: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
background: linear-gradient(to right, var(--lazuli-primary) 2px, color-mix(in srgb, var(--lazuli-primary), transparent) 2px, transparent 20%);
transition: height 100ms linear;
}
span.disabled:before {
height: 100%;
}
</style>
-102
View File
@@ -1,102 +0,0 @@
<script lang="ts">
import IconButton from './iconButton.svelte'
import MingcuteMenuLine from '~icons/mingcute/menu-line'
import { goto } from '$app/navigation'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
let searchInput: HTMLInputElement, searchBarWidth: number
const HIDE_SEARCHBAR_BREAKPOINT_PX = 300
$: showSearchbar = searchBarWidth > HIDE_SEARCHBAR_BREAKPOINT_PX
let miniSearchOpen: boolean = false
function search() {
if (searchInput.value.replace(/\s/g, '').length === 0) return
const searchParams = new URLSearchParams({ query: searchInput.value })
goto(`/search?${searchParams.toString()}`)
}
</script>
<nav id="navbar" class="grid h-[4.5rem] items-center gap-2.5 p-3.5">
{#if !miniSearchOpen}
<div class="mr-4 flex h-full items-center">
<div class="h-full p-1">
<IconButton halo={true} on:click={() => dispatch('opensidebar')}>
<MingcuteMenuLine slot="icon" class="text-lg" />
</IconButton>
</div>
<!-- --------------This is a placeholder image-------------- -->
<button on:click={() => goto('/')} class="mx-2.5 h-full w-20 bg-center bg-no-repeat" style="background-image: url(https://music.youtube.com/img/on_platform_logo_dark.svg);" />
</div>
<div bind:clientWidth={searchBarWidth} class="h-full">
{#if showSearchbar}
<search
role="search"
class="relative flex h-full w-full max-w-xl items-center gap-2.5 rounded-lg border border-[rgba(255,255,255,0.1)] px-4 py-2 text-neutral-400"
style="background-color: rgba(255,255,255, 0.07);"
>
<IconButton on:click={search}>
<i slot="icon" class="fa-solid fa-magnifying-glass" />
</IconButton>
<input
bind:this={searchInput}
type="search"
name="search"
class="h-full w-full text-ellipsis bg-transparent text-neutral-300 outline-none placeholder:text-neutral-400"
placeholder="Let's find some music"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
on:keypress={(event) => (event.key === 'Enter' ? search() : null)}
/>
<IconButton on:click={() => (searchInput.value = '')}>
<i slot="icon" class="fa-solid fa-xmark" />
</IconButton>
</search>
{/if}
</div>
<div class="flex h-full gap-3 justify-self-end p-1">
<IconButton halo={true} on:click={() => goto('/user')}>
<i slot="icon" class="fa-solid fa-user text-lg" />
</IconButton>
{#if !showSearchbar}
<IconButton on:click={() => (miniSearchOpen = true)}>
<i slot="icon" class="fa-solid fa-magnifying-glass text-lg" />
</IconButton>
{/if}
</div>
{:else}
<IconButton on:click={() => (miniSearchOpen = false)}>
<i slot="icon" class="fa-solid fa-arrow-left text-lg" />
</IconButton>
<input
bind:this={searchInput}
type="search"
name="search"
class="h-full w-full text-ellipsis bg-transparent font-medium text-neutral-300 caret-lazuli-primary outline-none placeholder:text-neutral-300"
placeholder="Let's find some music"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
on:keypress={(event) => (event.key === 'Enter' ? search() : null)}
/>
<IconButton on:click={() => (searchInput.value = '')}>
<i slot="icon" class="fa-solid fa-xmark text-lg" />
</IconButton>
{/if}
</nav>
<style>
#navbar {
grid-template-columns: min-content auto min-content;
}
input[type='search']::-webkit-search-cancel-button {
display: none;
}
</style>
@@ -1,72 +0,0 @@
<!--
@component
A component that can be injected with a text to display it in a single line that clips if overflowing and intermittently scrolls
from one end to the other.
```tsx
<slot name="text" /> // An HTML element to wrap and style the text you want to scroll (e.g. div, spans, strongs)
```
-->
<script lang="ts">
let slidingText: HTMLElement
let slidingTextWidth: number, slidingTextWrapperWidth: number
let scrollDirection: 1 | -1 = 1
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 30}s`
</script>
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-full w-full overflow-clip">
<span
bind:this={slidingText}
bind:clientWidth={slidingTextWidth}
on:animationend={() => (scrollDirection *= -1)}
id="scrollingText"
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} absolute whitespace-nowrap"
>
<slot name="text" />
</span>
<!-- This is so the wrapper can calculate how big it should be based on the text -->
<span class="pointer-events-none line-clamp-1 opacity-0">
<slot name="text" />
</span>
</div>
<style>
#scrollingText {
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: 10s;
}
#scrollingText:hover {
animation-play-state: paused;
}
.scrollLeft {
animation-name: scrollLeft;
}
.scrollRight {
animation-name: scrollRight;
}
@keyframes scrollLeft {
0% {
left: 0%;
transform: translateX(0%);
}
100% {
left: 100%;
transform: translateX(-100%);
}
}
@keyframes scrollRight {
0% {
left: 100%;
transform: translateX(-100%);
}
100% {
left: 0%;
transform: translateX(0%);
}
}
</style>
@@ -1,59 +0,0 @@
<script lang="ts">
export let type: ConnectionType
</script>
{#if type === 'jellyfin'}
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
<defs>
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
<stop offset="0" style="stop-color:#AA5CC3" />
<stop offset="1" style="stop-color:#00A4DC" />
</linearGradient>
</defs>
<title>icon-transparent</title>
<g id="icon-transparent">
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)" />
<path
id="outer-shape"
d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z"
fill="url(#linear-gradient)"
/>
</g>
</svg>
{:else if type === 'youtube-music'}
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 176 176" enable-background="new 0 0 176 176" xml:space="preserve">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="176" width="176" x="8" y="-184"></sliceSourceBounds>
</sfw>
</metadata>
<g id="XMLID_167_">
<circle id="XMLID_791_" fill="#FF0000" cx="88" cy="88" r="88" />
<path
id="XMLID_42_"
fill="#FFFFFF"
d="M88,46c23.1,0,42,18.8,42,42s-18.8,42-42,42s-42-18.8-42-42S64.9,46,88,46 M88,42
c-25.4,0-46,20.6-46,46s20.6,46,46,46s46-20.6,46-46S113.4,42,88,42L88,42z"
/>
<polygon id="XMLID_274_" fill="#FFFFFF" points="72,111 111,87 72,65" />
</g>
</svg>
{/if}
<style>
svg {
max-width: 100%;
max-height: 100%;
}
</style>
-68
View File
@@ -1,68 +0,0 @@
<script lang="ts">
import { slide, fade } from 'svelte/transition'
import { sineOut } from 'svelte/easing'
import { goto } from '$app/navigation'
import IconButton from './iconButton.svelte'
import PhPlaylistBold from '~icons/ph/playlist-bold'
import MaterialSymbolsHome from '~icons/material-symbols/home'
import IcSharpVideoLibrary from '~icons/ic/sharp-video-library'
type NavButton = {
name: string
path: string
icon: any
}
const navButtons: NavButton[] = [
{ name: 'Home', path: '/', icon: MaterialSymbolsHome },
{ name: 'Mixes', path: '/mixes', icon: PhPlaylistBold },
{ name: 'Library', path: '/library', icon: IcSharpVideoLibrary },
]
const OPEN_CLOSE_DURATION = 250
export function open() {
isOpen = true
}
export function close() {
isOpen = false
}
let isOpen: boolean = false
</script>
{#if isOpen}
<div class="fixed isolate z-50">
<div transition:fade={{ duration: OPEN_CLOSE_DURATION }} aria-hidden="true" class="fixed bottom-0 left-0 right-0 top-0" style="background-color: rgba(0,0,0,0.3);" />
<section id="sidebar-wrapper" class="fixed bottom-0 left-0 right-0 top-0">
<div transition:slide={{ duration: OPEN_CLOSE_DURATION, axis: 'x', easing: sineOut }} class="relative h-full w-full overflow-clip bg-neutral-950 py-4 text-neutral-300 shadow-2xl">
{#each navButtons as tab}
<button
on:click={() => {
goto(tab.path)
close()
}}
class="flex w-full items-center gap-6 px-10 py-3.5 text-left transition-colors hover:bg-[rgba(255,255,255,0.1)]"
>
<svelte:component this={tab.icon} class="text-lg" />
{tab.name}
</button>
{/each}
<div class="absolute bottom-3 right-3 aspect-square h-10">
<IconButton on:click={close}>
<i slot="icon" class="fa-solid fa-arrow-left" />
</IconButton>
</div>
</div>
<div aria-hidden="true" on:click={close} class="h-full w-full" />
</section>
</div>
{/if}
<style>
#sidebar-wrapper {
display: grid;
grid-template-columns: minmax(auto, 20rem) minmax(4rem, auto);
}
</style>
-74
View File
@@ -1,74 +0,0 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
export let value = 0
export let max = 100
export let thickness: 'thick' | 'thin' = 'thick'
const seekingDispatch = createEventDispatcher<{ seeking: { value: number } }>()
const seekedDispatch = createEventDispatcher<{ seeked: { value: number } }>()
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
const trackThumb = (sliderPos: number): void => {
if (sliderThumb) sliderThumb.style.left = `${(sliderPos / max) * 100}%`
if (sliderTrail) sliderTrail.style.right = `${100 - (sliderPos / max) * 100}%`
}
$: trackThumb(value)
onMount(() => trackThumb(value))
const keyPressJumpIntervalCount = 20
const handleKeyPress = (key: string) => {
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < max) value = Math.min(max, value + max / keyPressJumpIntervalCount)
if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) value = Math.max(0, value - max / keyPressJumpIntervalCount) // For some reason this is kinda broken
}
</script>
<div
id="slider-track"
class="relative isolate {thickness === 'thick' ? 'h-1' : 'h-0.5'} w-full rounded bg-neutral-800"
style="--slider-color: var(--lazuli-primary)"
role="slider"
tabindex="0"
aria-valuenow={value}
aria-valuemin="0"
aria-valuemax={max}
on:keydown={(event) => handleKeyPress(event.key)}
>
<input
on:input={(event) => seekingDispatch('seeking', { value: Number(event.currentTarget.value) })}
on:change={(event) => seekedDispatch('seeked', { value: Number(event.currentTarget.value) })}
type="range"
class="absolute z-10 {thickness === 'thick' ? 'h-1' : 'h-0.5'} w-full"
step="any"
min="0"
{max}
bind:value
tabindex="-1"
aria-hidden="true"
aria-disabled="true"
/>
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 {thickness === 'thick' ? 'h-1' : 'h-0.5'} rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square {thickness === 'thick' ? 'h-3.5' : 'h-2.5'} -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0" />
</div>
<style>
input[type='range'] {
appearance: none;
cursor: pointer;
opacity: 0;
}
#slider-track:hover > #slider-trail,
#slider-track:focus > #slider-trail {
background-color: var(--slider-color);
}
#slider-track:hover > #slider-thumb,
#slider-track:focus > #slider-thumb {
opacity: 1;
}
#slider-track:not(:hover):not(:focus) > #slider-trail {
transition: right 50ms linear;
}
</style>
@@ -1,47 +1,41 @@
<script context="module" lang="ts">
export type AlertType = 'info' | 'success' | 'warning' | 'caution'
</script>
<script lang="ts">
export let alertType: AlertType
export let alertMessage: string
import { onMount, createEventDispatcher } from 'svelte'
import { slide, fly } from 'svelte/transition'
let show: boolean = false
const dispatch = createEventDispatcher<{ closeAlert: null }>()
type BgColors = {
[key in AlertType]: string
}
const bgColors: BgColors = {
info: 'bg-neutral-500',
success: 'bg-emerald-500',
caution: 'bg-amber-500',
warning: 'bg-red-500',
}
export const triggerClose = () => {
show = false
dispatch('closeAlert')
}
onMount(() => {
show = true
setTimeout(() => triggerClose(), 10000)
})
</script>
{#if show}
<div in:fly={{ x: 500 }} out:slide={{ axis: 'y' }} class="m-2">
<div class="flex gap-1 overflow-hidden rounded-md">
<div class="flex w-full items-center p-4 {bgColors[alertType]}">
{alertMessage}
</div>
<button class="w-16 {bgColors[alertType]}" on:click={() => triggerClose()}>
<i class="fa-solid fa-x" />
</button>
</div>
</div>
{/if}
<script>
export let alertType
export let alertMessage
import { onMount } from 'svelte'
import { slide } from 'svelte/transition'
import { fly } from 'svelte/transition'
import { createEventDispatcher } from 'svelte'
let show = false
const dispatch = createEventDispatcher()
const bgColors = {
info: 'bg-neutral-500',
success: 'bg-emerald-500',
caution: 'bg-amber-500',
warning: 'bg-red-500',
}
export const triggerClose = () => {
show = false
dispatch('closeAlert')
}
onMount(() => {
show = true
setTimeout(() => triggerClose(), 10000)
})
</script>
{#if show}
<div in:fly={{ duration: 300, x: 500 }} out:slide={{ duration: 300, axis: 'y' }} class="py-1">
<div class="flex gap-1 overflow-hidden rounded-md">
<div class="flex w-full items-center p-4 {bgColors[alertType]}">
{alertMessage}
</div>
<button class="w-16 {bgColors[alertType]}" on:click={() => triggerClose()}>
<i class="fa-solid fa-x" />
</button>
</div>
</div>
{/if}
@@ -1,26 +1,30 @@
<script lang="ts">
import Alert from './alert.svelte'
import type { AlertType } from './alert.svelte'
let alertBox: HTMLDivElement
let alertQueue: Alert[] = []
export const addAlert = (alertType: AlertType, alertMessage: string) => {
if (alertQueue.length > 5) alertQueue[0].triggerClose()
const alert = new Alert({
target: alertBox,
props: { alertType, alertMessage },
})
alert.$on('closeAlert', () => {
const index = alertQueue.indexOf(alert)
if (index > -1) alertQueue.splice(index, 1)
setTimeout(() => alert.$destroy(), 300)
})
alertQueue.push(alert)
}
</script>
<div bind:this={alertBox} class="fixed right-0 top-0 z-50 max-h-screen w-full max-w-sm overflow-hidden"></div>
<script>
import Alert from './alert.svelte'
let alertBox
let alertQueue = []
export const addAlert = (alertType, alertMessage) => {
if (alertQueue.length > 5) {
alertQueue[0].triggerClose()
}
const alert = new Alert({
target: alertBox,
props: {
alertType: alertType,
alertMessage: alertMessage,
},
})
alert.$on('closeAlert', () => {
const index = alertQueue.indexOf(alert)
if (index > -1) alertQueue.splice(index, 1)
setTimeout(() => alert.$destroy(), 300)
})
alertQueue.push(alert)
}
</script>
<div bind:this={alertBox} class="fixed right-0 top-0 z-50 max-h-screen w-full max-w-sm overflow-hidden p-4"></div>
@@ -0,0 +1,73 @@
<script>
import { fade, slide } from 'svelte/transition'
import { spin } from '$lib/utils/animations'
import { page } from '$app/stores'
export let alignDropdown = 'left'
const align = {
left: 'left-0',
right: 'right-0',
center: 'left-1/2 -translate-x-1/2',
}[alignDropdown]
let button,
icon,
open = false
$: $page.url, closeMenu()
const closeMenu = () => {
if (button && open) {
button.animate(spin(), 400)
open = false
}
}
</script>
<div class="relative aspect-square h-full">
<button
bind:this={button}
id="button"
class="grid h-full w-full place-items-center transition-transform duration-75 active:scale-90"
on:click={() => {
button.animate(spin(), 400)
open = !open
}}
>
{#if open}
<i id="menu-icon" transition:fade={{ duration: 300 }} bind:this={icon} class="fa-solid fa-xmark" />
{:else}
<i id="menu-icon" transition:fade={{ duration: 300 }} bind:this={icon} class="fa-solid fa-bars" />
{/if}
</button>
{#if open}
<section transition:slide={{ axis: 'y' }} class="absolute top-full {align}">
<slot name="menu-items" />
</section>
{/if}
</div>
<style>
#button::before {
content: '';
width: 0;
height: 0;
background-color: color-mix(in srgb, var(--lazuli-primary) 20%, transparent);
border-radius: 100%;
transition-property: width height;
transition-duration: 200ms;
position: absolute;
}
#menu-icon {
font-size: 1.5rem;
position: absolute;
transition: color 200ms;
}
#button:hover > i {
color: var(--lazuli-primary);
}
#button:hover::before {
width: 130%;
height: 130%;
}
</style>
@@ -1,49 +1,41 @@
<script lang="ts">
export let toggled = false
export let disabled = false
export let halo = false
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<button
class:toggled
class:disabled
class:halo
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
on:click|preventDefault|stopPropagation={() => dispatch('click')}
{disabled}
>
<slot name="icon" />
</button>
<style>
button.disabled {
color: rgb(82, 82, 82);
}
button:not(.disabled).halo::before {
content: '';
width: 0;
height: 0;
background-color: color-mix(in srgb, var(--color, var(--lazuli-primary)) 20%, transparent);
border-radius: 100%;
transition-property: width height;
transition-duration: 200ms;
position: absolute;
}
button:not(.disabled).halo:hover::before {
width: 130%;
height: 130%;
}
button :global(> :first-child) {
transition: color 200ms;
}
button:not(.disabled).toggled :global(> :first-child) {
color: var(--color, var(--lazuli-primary));
}
button:not(.disabled):not(.toggled):hover :global(> :first-child) {
color: var(--hover-color, var(--color, var(--lazuli-primary)));
}
</style>
<script>
export let disabled = false
export let halo = true
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<button
class:disabled
class:halo
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90 {disabled ? 'text-neutral-600' : ''}"
on:click|preventDefault={() => dispatch('click')}
{disabled}
>
<slot name="icon" />
</button>
<style>
button:not(.disabled).halo::before {
content: '';
width: 0;
height: 0;
background-color: color-mix(in srgb, var(--lazuli-primary) 20%, transparent);
border-radius: 100%;
transition-property: width height;
transition-duration: 200ms;
position: absolute;
}
button:not(.disabled).halo:hover::before {
width: 130%;
height: 130%;
}
button :global(> :first-child) {
transition: color 200ms;
}
button:not(.disabled):hover :global(> :first-child) {
color: var(--lazuli-primary);
}
</style>
@@ -0,0 +1,70 @@
<script>
let searchBar, searchInput
let searchOpen = false
let searchRecommendations = null
const toggleSearchMenu = (open) => {
searchOpen = open
if (open) {
searchBar.style.borderColor = 'rgb(100, 100, 100)'
searchRecommendations = ['Psycho Lily', 'Iceborn', 'HYPER4ID', 'Parousia', 'Ragnarok', 'Betwixt & Between']
} else {
searchBar.style.borderColor = 'transparent'
searchRecommendations = null
}
}
const triggerSearch = (searchQuery) => {
console.log(`Search for: ${searchQuery}`)
// Redirect To '/search' route with query parameter '?query=searchQuery'
}
</script>
<search
role="search"
bind:this={searchBar}
class="relative flex h-full w-full items-center gap-2.5 justify-self-center rounded-full border-2 border-transparent bg-black px-4 py-2"
on:focusout={() => {
setTimeout(() => {
// This is a completely stupid thing you have to do, if there is not timeout, the active element will be the body of the document and not the newly focused element
if (!searchBar.contains(document.activeElement)) {
toggleSearchMenu(false)
}
}, 1)
}}
>
<button
on:click|preventDefault={(event) => {
if (searchInput.value.trim() === '') {
if (event.pointerType === 'mouse') toggleSearchMenu(!searchOpen)
if (searchOpen) searchInput.focus()
} else {
triggerSearch(searchInput.value)
}
}}
>
<i class="fa-solid fa-magnifying-glass transition-colors duration-200 hover:text-lazuli-primary" />
</button>
<input
bind:this={searchInput}
type="text"
name="search"
class="w-full bg-transparent outline-none"
placeholder="Let's find some music"
autocomplete="off"
on:focus={() => toggleSearchMenu(true)}
on:keypress={(event) => {
if (event.key === 'Enter') triggerSearch(searchInput.value)
}}
/>
{#if searchRecommendations}
<div class="absolute left-0 top-full flex w-full flex-col bg-neutral-950">
{#each searchRecommendations as recommendation}
<button class="w-full p-4 text-left" on:click|preventDefault={() => triggerSearch(recommendation)}>
{recommendation}
</button>
{/each}
</div>
{/if}
</search>
+52
View File
@@ -0,0 +1,52 @@
<script>
export let value = 0
let sliderTrail, sliderThumb
const trackThumb = (sliderPos) => {
if (sliderThumb) sliderThumb.style.left = `${sliderPos}%`
if (sliderTrail) sliderTrail.style.right = `${100 - sliderPos}%`
}
$: trackThumb(value)
const handleKeyPress = (key) => {
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < 100) return (value += 1)
if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) return (value -= 1)
}
</script>
<div
id="slider-track"
class="relative isolate h-1 w-full rounded-full bg-neutral-600"
role="slider"
tabindex="0"
aria-valuenow={value}
aria-valuemin="0"
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" />
</div>
<style>
input[type='range'] {
appearance: none;
cursor: pointer;
opacity: 0;
}
#slider-track:hover > #slider-trail {
background-color: var(--lazuli-primary);
}
#slider-track:focus > #slider-trail {
background-color: var(--lazuli-primary);
}
#slider-track:hover > #slider-thumb {
opacity: 1;
}
#slider-track:focus > #slider-thumb {
opacity: 1;
}
</style>
@@ -1,29 +1,30 @@
<script lang="ts">
export let toggled = false
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
const handleToggle = (): void => {
toggled = !toggled
dispatch('toggled', { toggled })
}
</script>
<button class:toggled aria-checked={toggled} role="checkbox" class="relative flex h-6 w-10 items-center rounded-full bg-neutral-500 transition-colors" on:click={handleToggle}>
<div class:toggled class="absolute left-0 aspect-square h-full p-1 transition-all">
<div class="grid h-full w-full place-items-center rounded-full bg-white text-xs">
<i class={toggled ? 'fa-solid fa-check text-lazuli-primary' : 'fa-solid fa-xmark text-neutral-500'} />
</div>
</div>
</button>
<style>
button.toggled {
background-color: var(--lazuli-primary);
}
div.toggled {
left: 100%;
transform: translateX(-100%);
}
</style>
<script>
export let toggled = false
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
const handleToggle = () => {
toggled = !toggled
dispatch('toggled', { toggled })
}
</script>
<button class:toggled aria-checked={toggled} role="checkbox" class="relative flex h-6 w-10 items-center rounded-full bg-neutral-500 transition-colors" on:click={handleToggle}>
<div class:toggled class="absolute left-0 aspect-square h-full p-1 transition-all">
<div class="grid h-full w-full place-items-center rounded-full bg-white">
<i class={toggled ? 'fa-solid fa-check text-xs' : 'fa-solid fa-xmark text-xs'} />
</div>
</div>
</button>
<style>
button.toggled {
background-color: var(--lazuli-primary);
}
div.toggled {
left: 100%;
transform: translateX(-100%);
color: var(--lazuli-primary);
}
</style>
@@ -0,0 +1,38 @@
<script>
import Slider from '$lib/components/utility/slider.svelte'
import IconButton from '$lib/components/utility/iconButton.svelte'
import { getVolume, setVolume } from '$lib/utils/utils.js'
import { onMount } from 'svelte'
export let volume = 0
let muted = false
let storedVolume
onMount(() => (storedVolume = getVolume()))
$: changeVolume(storedVolume)
const changeVolume = (newVolume) => {
if (typeof newVolume === 'number' && !isNaN(newVolume)) setVolume(newVolume)
}
$: volume = muted ? 0 : storedVolume
</script>
<div id="volume-slider" class="flex h-10 w-fit flex-shrink-0 flex-row-reverse items-center gap-2">
<IconButton halo={false} on:click={() => (muted = !muted)}>
<i slot="icon" class="fa-solid {volume > 50 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center text-base" />
</IconButton>
<div id="slider-wrapper" class="w-0 transition-all duration-500">
<Slider bind:value={storedVolume} />
</div>
</div>
<style>
#volume-slider:hover > #slider-wrapper {
width: 6rem;
}
#slider-wrapper:focus-within {
width: 6rem;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
{
"connectionId": "Id of the connection that provides the song [ALL REQ]",
"serviceType": "The type of service that provides the song [ALL REQ]",
"mediaType": "song || album || playlist || artist [ALL REQ]",
"name": "Name of media [ALL OPT]",
"id": "whatever unique identifier the service provides [ALL REQ]",
"duration": "length of song in milliseconds [song OPT, album OPT, playlist OPT]",
"artists [song OPT]": [
{
"name": "Name of artist",
"id": "service's unique identifier for the artist"
}
],
"album [song OPT]": {
"name": "Name of album",
"id": "service's unique identifier for the album",
"artists": [
{
"name": "Name of artist",
"id": "service's unique identifier for the artist"
}
],
"image": "source url of the album art"
},
"image": "source url of image unique to the song, if one does not exist this will be the album art or in the case of videos the thumbnail [ALL OPT]",
"audio": "source url of the audio stream [song REQ]",
"video": "source url of the video stream (if this is not null then player will allow for video mode, otherwise use image) [song OPT]",
"releaseDate": "Either the date the MV was upload or the release date/year of the album [song OPT, album OPT]",
"All of the above data": "Is comprised solely of data that has been read from the respective service and processed by a factory",
"the above data should not contain or rely on any information that has to be read from the lazuli server": "save for the data that was read to fetch it in the first place (connectionId, serviceType, etc.)",
"data that requires something else to be read from the lazuli server, such as presence in a playlist or marked as favorite": "should not be implemented into any visual design and is to be fetched as needed",
"When it comes to determining whether or not a song should be displayed as a video": "This is determined solely by the presence of a video source url, which will automatically be null for all sources besides youtube obviously",
"If a song does have a video source, it will be dispayed as a music video by default": "The presence of this property will likely determine certain styling, as well as the presence of the song/video switcher in the player"
}
+52
View File
@@ -0,0 +1,52 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
},
"spotify": {
"displayName": "Spotify",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
},
"apple-music": {
"displayName": "Apple Music",
"type": ["streaming", "marketplace"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg"
},
"bandcamp": {
"displayName": "bandcamp",
"type": ["marketplace", "streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg"
},
"soundcloud": {
"displayName": "SoundCloud",
"type": ["streaming"],
"icon": "https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg"
},
"lastfm": {
"displayName": "Last.fm",
"type": ["analytics"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg"
},
"plex": {
"displayName": "Plex",
"type": ["streaming"],
"icon": "https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg"
},
"deezer": {
"displayName": "deezer",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg"
},
"amazon-music": {
"displayName": "Amazon Music",
"type": ["streaming", "marketplace"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg"
}
}
-56
View File
@@ -1,56 +0,0 @@
import { DB, type Schemas } from './db'
import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music'
export async function userExists(userId: string): Promise<boolean> {
return Boolean(await DB.users.where('id', userId).first(DB.db.raw('EXISTS(SELECT 1)')))
}
export async function connectionExists(connectionId: string): Promise<boolean> {
return Boolean(await DB.connections.where('id', connectionId).first(DB.db.raw('EXISTS(SELECT 1)')))
}
export async function mixExists(mixId: string): Promise<boolean> {
return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)')))
}
export class ConnectionFactory {
/**
* Queries the database for a specific connection.
*
* @param {string} id The id of the connection
* @returns {Promise<Connection>} An instance of a Connection
* @throws {ReferenceError} ReferenceError if there is no connection with an id matches the one passed
*/
public static async getConnection(id: string): Promise<Connection> {
const schema = await DB.connections.where('id', id).first()
if (!schema) throw ReferenceError(`Connection of Id ${id} does not exist`)
return this.createConnection(schema)
}
/**
* Queries the database for all connections belong to a user of the specified id.
*
* @param {string} userId The id of a user
* @returns {Promise<Connection[]>} An array of connection instances for each of the user's connections
* @throws {ReferenceError} ReferenceError if there is no user with an id matches the one passed
*/
public static async getUserConnections(userId: string): Promise<Connection[]> {
const validUserId = await userExists(userId)
if (!validUserId) throw ReferenceError(`User of Id ${userId} does not exist`)
const connectionSchemas = await DB.connections.where('userId', userId).select('*')
return connectionSchemas.map(this.createConnection)
}
private static createConnection(schema: Schemas.Connections): Connection {
const { id, userId, type, serviceUserId, accessToken } = schema
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, serviceUserId, schema.serverUrl, accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, serviceUserId, accessToken, schema.refreshToken, schema.expiry)
}
}
}
-182
View File
@@ -1,182 +0,0 @@
import knex from 'knex'
const connectionTypes = ['jellyfin', 'youtube-music']
export declare namespace Schemas {
interface Users {
id: string
username: string
passwordHash: string
}
interface JellyfinConnection {
id: string
userId: string
type: 'jellyfin'
serviceUserId: string
serverUrl: string
accessToken: string
}
interface YouTubeMusicConnection {
id: string
userId: string
type: 'youtube-music'
serviceUserId: string
accessToken: string
refreshToken: string
expiry: number
}
type Connections = JellyfinConnection | YouTubeMusicConnection
interface Mixes {
id: string
userId: string
name: string
thumbnailTag?: string
description?: string
trackCount: number
duration: number
}
interface MixItems {
mixId: string
connectionId: string
connectionType: ConnectionType
id: string
index: number
}
interface Songs {
connectionId: string
connectionType: ConnectionType
id: string
name: string
duration: number
thumbnailUrl: string
releaseDate?: string
artists?: {
id: string
name: string
}[]
album?: {
id: string
name: string
}
uploader?: {
id: string
name: string
}
isVideo: boolean
}
}
class Database {
public readonly db: knex.Knex
constructor(db: knex.Knex<'better-sqlite3'>) {
this.db = db
}
public uuid() {
return this.db.fn.uuid()
}
public get users() {
return this.db<Schemas.Users>('Users')
}
public get connections() {
return this.db<Schemas.Connections>('Connections')
}
public get mixes() {
return this.db<Schemas.Mixes>('Mixes')
}
public get mixItems() {
return this.db<Schemas.MixItems>('MixItems')
}
public get songs() {
return this.db<Schemas.Songs>('Songs')
}
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Users')
if (exists) return
await db.schema.createTable('Users', (tb) => {
tb.uuid('id').primary(), tb.string('username').unique().notNullable().checkLength('<=', 30), tb.string('passwordHash').notNullable().checkLength('=', 60)
})
}
public static async createConnectionsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Connections')
if (exists) return
await db.schema.createTable('Connections', (tb) => {
tb.uuid('id').primary(),
tb.uuid('userId').notNullable().references('id').inTable('Users'),
tb.enum('type', connectionTypes).notNullable(),
tb.string('serviceUserId'),
tb.string('serverUrl'),
tb.string('accessToken'),
tb.string('refreshToken'),
tb.integer('expiry')
})
}
public static async createMixesTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Mixes')
if (exists) return
await db.schema.createTable('Mixes', (tb) => {
tb.uuid('id').primary(),
tb.uuid('userId').notNullable().references('id').inTable('Users'),
tb.string('name').notNullable(),
tb.uuid('thumbnailTag'),
tb.string('description'),
tb.integer('trackCount').notNullable(),
tb.integer('duration').notNullable()
})
}
public static async createMixItemsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('MixItems')
if (exists) return
await db.schema.createTable('MixItems', (tb) => {
tb.uuid('mixId').notNullable().references('id').inTable('Mixes'),
tb.uuid('connectionId').notNullable().references('id').inTable('Connections'),
tb.enum('connectionType', connectionTypes).notNullable(),
tb.string('id').notNullable()
tb.integer('index').notNullable()
})
}
public static async createSongsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Songs')
if (exists) return
await db.schema.createTable('Songs', (tb) => {
tb.uuid('connectionId').notNullable().references('id').inTable('Connections'),
tb.enum('connectionType', connectionTypes),
tb.string('id').notNullable(),
tb.string('name').notNullable(),
tb.integer('duration').notNullable(),
tb.string('thumbnailUrl').notNullable(),
tb.datetime('releaseDate', { precision: 3 }),
tb.json('artists'),
tb.json('album'),
tb.json('uploader'),
tb.boolean('isVideo').notNullable()
})
}
}
const db = knex<'better-sqlite3'>({ client: 'better-sqlite3', connection: { filename: './src/lib/server/lazuli.db' }, useNullAsDefault: false })
await Promise.all([Database.createUsersTable(db), Database.createConnectionsTable(db), Database.createMixesTable(db), Database.createMixItemsTable(db), Database.createSongsTable(db)])
export const DB = new Database(db)
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
import Database from 'better-sqlite3'
import Services from '$lib/services.json'
const db = new Database('./src/lib/server/db/users.db', { verbose: console.info })
db.pragma('foreign_keys = ON')
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
const initUserConnectionsTable =
'CREATE TABLE IF NOT EXISTS UserConnections(id VARCHAR(36) PRIMARY KEY, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, serviceUserId TEXT NOT NULL, serviceUrl TEXT NOT NULL, accessToken TEXT NOT NULL, refreshToken TEXT, expiry INTEGER, FOREIGN KEY(userId) REFERENCES Users(id))'
db.exec(initUsersTable)
db.exec(initUserConnectionsTable)
export class Users {
static addUser = (username, hashedPassword) => {
try {
db.prepare('INSERT INTO Users(username, password) VALUES(?, ?)').run(username, hashedPassword)
return this.queryUsername(username)
} catch {
return null
}
}
static queryUsername = (username) => {
return db.prepare('SELECT * FROM Users WHERE lower(username) = ?').get(username.toLowerCase())
}
}
export class UserConnections {
static validServices = Object.keys(Services)
static getConnection = (id) => {
return db.prepare(`SELECT * FROM UserConnections WHERE id = ?`).get(id)
}
static getUserConnections = (userId) => {
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ?`).all(userId)
if (connections.length === 0) return null
return connections
}
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
static addConnection = (userId, serviceType, serviceUserId, serviceUrl, accessToken, additionalApiData = {}) => {
const { refreshToken = null, expiry = null } = additionalApiData
if (!this.validServices.includes(serviceType)) throw new Error(`Service name ${serviceType} is invalid`)
const connectionId = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
db.prepare('INSERT INTO UserConnections(id, userId, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?, ?, ?, ?)').run(
connectionId,
userId,
serviceType,
serviceUserId,
serviceUrl,
accessToken,
refreshToken,
expiry,
)
return connectionId
}
static deleteConnection = (userId, serviceId) => {
const commandInfo = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND id = ?').run(userId, serviceId)
if (!commandInfo.changes === 0) throw new Error(`User does not have connection with id: ${serviceId}`)
}
}
-78
View File
@@ -1,78 +0,0 @@
export namespace JellyfinAPI {
type Song = {
Name: string
Id: string
Type: 'Audio'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
Id: string
}[]
Album?: string
AlbumId?: string
AlbumPrimaryImageTag?: string
AlbumArtists?: {
Name: string
Id: string
}[]
ImageTags?: {
Primary?: string
}
}
type Album = {
Name: string
Id: string
Type: 'MusicAlbum'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
Id: string
}[]
AlbumArtists?: {
Name: string
Id: string
}[]
ImageTags?: {
Primary?: string
}
}
type Artist = {
Name: string
Id: string
Type: 'MusicArtist'
ImageTags?: {
Primary?: string
}
}
type Playlist = {
Name: string
Id: string
Type: 'Playlist'
RunTimeTicks: number
ChildCount: number
ImageTags?: {
Primary?: string
}
}
interface UserResponse {
Name: string
Id: string
}
interface AuthenticationResponse {
User: JellyfinAPI.UserResponse
AccessToken: string
}
interface SystemResponse {
ServerName: string
}
}
-256
View File
@@ -1,256 +0,0 @@
import { PUBLIC_VERSION } from '$env/static/public'
import type { JellyfinAPI } from './jellyfin-types'
import ky, { HTTPError, type KyInstance } from 'ky'
const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg'
export class Jellyfin implements Connection {
public readonly id: string
private readonly userId: string
private readonly jellyfinUserId: string
private readonly serverUrl: string
private readonly parsers: JellyfinParsers
private libraryManager?: JellyfinLibraryManager
private readonly api: KyInstance
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id
this.userId = userId
this.jellyfinUserId = jellyfinUserId
this.serverUrl = serverUrl
this.parsers = new JellyfinParsers(this.id, serverUrl)
const errorHook = (error: HTTPError) => {
console.error(`Request to ${new URL(error.request.url).pathname} failed: ${error.message} ${error.response.status}`)
return error
}
this.api = ky.create({
prefixUrl: serverUrl,
headers: { Authorization: `MediaBrowser Token="${accessToken}"` },
hooks: { beforeError: [errorHook] },
})
}
public get library() {
if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.api, this.parsers)
return this.libraryManager
}
// * This method can NOT throw an error
public async getConnectionInfo() {
const getUserData = () =>
this.api(`Users/${this.jellyfinUserId}`)
.json<JellyfinAPI.UserResponse>()
.catch(() => null)
const getSystemData = () =>
this.api('System/Info')
.json<JellyfinAPI.SystemResponse>()
.catch(() => null)
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
return {
id: this.id,
userId: this.userId,
type: 'jellyfin',
serverUrl: this.serverUrl,
serverName: systemData?.ServerName,
jellyfinUserId: this.jellyfinUserId,
username: userData?.Name,
} satisfies ConnectionInfo
}
public async search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]> {
const filterMap = { song: 'Audio', album: 'MusicAlbum', artist: 'MusicArtist', playlist: 'Playlist' } as const
const searchParams = new URLSearchParams({
searchTerm,
includeItemTypes: Array.from(types, (type) => filterMap[type]).join(','),
recursive: 'true',
})
const searchResults = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>()
return searchResults.Items.map((result) => {
switch (result.Type) {
case 'Audio':
return this.parsers.parseSong(result)
case 'MusicAlbum':
return this.parsers.parseAlbum(result)
case 'MusicArtist':
return this.parsers.parseArtist(result)
case 'Playlist':
return this.parsers.parsePlaylist(result)
}
}) as MediaItemTypeMap[T][]
}
// Temporary implementation, I'll actually make something better later
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
const searchParams = new URLSearchParams({
SortBy: 'PlayCount',
SortOrder: 'Descending',
IncludeItemTypes: 'Audio',
Recursive: 'true',
limit: '10',
})
const mostPlayedResponse = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: JellyfinAPI.Song[] }>()
return mostPlayedResponse.Items.map(this.parsers.parseSong)
}
// ! OK apparently some just don't work at all sometimes?
public async getAudioStream(id: string, headers: Headers) {
const audoSearchParams = new URLSearchParams({
MaxStreamingBitrate: '140000000',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
TranscodingContainer: 'ts',
TranscodingProtocol: 'hls',
AudioCodec: 'aac',
userId: this.jellyfinUserId,
})
return this.api(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true })
}
public async getAlbum(id: string) {
return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json<JellyfinAPI.Album>().then(this.parsers.parseAlbum)
}
public async getAlbumItems(id: string) {
const searchParams = new URLSearchParams({
parentId: id,
sortBy: 'ParentIndexNumber,IndexNumber,SortName',
})
return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.json<{ Items: JellyfinAPI.Song[] }>()
.then((response) => response.Items.map(this.parsers.parseSong))
}
public async getPlaylist(id: string) {
return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json<JellyfinAPI.Playlist>().then(this.parsers.parsePlaylist)
}
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }) {
const searchParams = new URLSearchParams({
parentId: id,
includeItemTypes: 'Audio',
})
if (options?.startIndex) searchParams.append('startIndex', options.startIndex.toString())
if (options?.limit) searchParams.append('limit', options.limit.toString())
return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.json<{ Items: JellyfinAPI.Song[] }>()
.then((response) => response.Items.map(this.parsers.parseSong))
}
public static async authenticateByName(username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> {
return ky
.post(new URL('Users/AuthenticateByName', serverUrl.origin), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`,
},
json: {
Username: username,
Pw: password,
},
})
.json<JellyfinAPI.AuthenticationResponse>()
}
}
class JellyfinParsers {
private readonly connectionId: string
private readonly serverUrl: string
constructor(connectionId: string, serverUrl: string) {
this.connectionId = connectionId
this.serverUrl = serverUrl
}
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder: string): string
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined {
const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined
return imageItemId ? new URL(`Items/${imageItemId}/Images/Primary`, this.serverUrl).toString() : placeholder
}
public parseSong = (song: JellyfinAPI.Song): Song => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: song.Id,
name: song.Name,
type: 'song',
duration: Math.round(song.RunTimeTicks / 10000000),
thumbnailUrl: this.getBestThumbnail(song, jellyfinLogo),
releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined,
artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })),
album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined,
isVideo: false,
})
public parseAlbum = (album: JellyfinAPI.Album): Album => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: album.Id,
name: album.Name,
type: 'album',
thumbnailUrl: this.getBestThumbnail(album, jellyfinLogo),
artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists',
releaseYear: album.ProductionYear?.toString(),
})
public parseArtist = (artist: JellyfinAPI.Artist): Artist => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: artist.Id,
name: artist.Name,
type: 'artist',
profilePicture: this.getBestThumbnail(artist),
})
public parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: playlist.Id,
name: playlist.Name,
type: 'playlist',
thumbnailUrl: this.getBestThumbnail(playlist, jellyfinLogo),
})
}
class JellyfinLibraryManager {
private readonly jellyfinUserId: string
private readonly api: KyInstance
private readonly parsers: JellyfinParsers
constructor(jellyfinUserId: string, api: KyInstance, parsers: JellyfinParsers) {
this.jellyfinUserId = jellyfinUserId
this.api = api
this.parsers = parsers
}
public async albums(): Promise<Album[]> {
return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`)
.json<{ Items: JellyfinAPI.Album[] }>()
.then((response) => response.Items.map(this.parsers.parseAlbum))
}
public async artists(): Promise<Artist[]> {
// ? This returns just album artists instead of all artists like in finamp, but I might decide that I want to return all artists instead
return this.api('Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
.json<{ Items: JellyfinAPI.Artist[] }>()
.then((response) => response.Items.map(this.parsers.parseArtist))
}
public async playlists(): Promise<Playlist[]> {
return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`)
.json<{ Items: JellyfinAPI.Playlist[] }>()
.then((response) => response.Items.map(this.parsers.parsePlaylist))
}
}
File diff suppressed because it is too large Load Diff
-898
View File
@@ -1,898 +0,0 @@
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import type { InnerTube, YouTubeDataApi } from './youtube-music-types'
import { DB } from './db'
import ky, { type KyInstance } from 'ky'
export class YouTubeMusic implements Connection {
public readonly id: string
private readonly userId: string
private readonly youtubeUserId: string
private readonly api: API
private libraryManager?: YouTubeMusicLibrary
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
this.id = id
this.userId = userId
this.youtubeUserId = youtubeUserId
this.api = new API(id, accessToken, refreshToken, expiry)
}
public get library() {
if (!this.libraryManager) this.libraryManager = new YouTubeMusicLibrary(this.api)
return this.libraryManager
}
// * This method can NOT throw an error
public async getConnectionInfo() {
const response = await this.api.v1
.WEB_REMIX('browse', { json: { browseId: this.youtubeUserId } })
.json<InnerTube.User.Response>()
.catch(() => null)
const username = response?.header.musicVisualHeaderRenderer.title.runs[0].text
const profilePicture = response ? extractLargestThumbnailUrl(response.header.musicVisualHeaderRenderer.foregroundThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined
return {
id: this.id,
userId: this.userId,
type: 'youtube-music',
youtubeUserId: this.youtubeUserId,
username,
profilePicture,
} satisfies ConnectionInfo
}
public async search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]> {
const searchFilterParams = {
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
video: 'EgWKAQIQAWoMEA4QChADEAQQCRAF',
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
playlist: 'EgeKAQQoAEABagwQDhAKEAMQBBAJEAU%3D',
} as const
const searchType = async (type: keyof typeof searchFilterParams) => this.api.v1.WEB_REMIX('search', { json: { query: searchTerm, params: searchFilterParams[type] } }).json<InnerTube.Search.Response>()
const extendedTypes = new Set<keyof typeof searchFilterParams>(types)
if (extendedTypes.has('song')) extendedTypes.add('video')
const searchResponses = await Promise.all(Array.from(extendedTypes, searchType))
// Ok so I have a problem here. Firstly, the youtube music flavor of search is fucking abyssmal. You get like at most 3 results for each type of
// content and most of it is completely irrelavent and not even close to what you search for. On top of that it won't even try to return
// livestreams or past completed broadcasts. The standard youtube search is far superior however the pain point there is you are either getting
// a video (which includes livestream content), a channel, or a playlist, which does not line up super well with the current Song, Album, Artist
// Playlist architecture. For non-livestream videos I could put the results throught the getSongs() method which will scrape the counterparts,
// but there is really no way to get albums or or determine if a channel is an artist or an uploader. I guess I could query both with filters but
// the minimum of like five API calls + the parsing just sounds like such an bad time.
// Now that I think about it, I don't really know how I want to do search. Returning finite results would make my life easier but I think that's
// just not a good idea. Acutally it seems most streaming services do limit the number of seach results. Only problem is, IDK how I'm supposed to
// implement a limt query param in the search endpoint when the I'm getting all of the content from many different APIs, *some of which* (fucking yt music),
// don't provide a way to limit the number of results returned
// Holy fuck it gets even worse. The v3 API does not even allow you get anything beyond the "snippet" for completed live streams, meaning no duration or high res
// thumbnails (at most it returns like a 360p). On top of that we can't use the getSongs() method either because it will return an error response (INVALID_ARGUMENT).
// I think I'm just going to have to bite the bullet query both the YTMusic API and the standard youtube search v1 API.
// NOTE:
// To ensure best result we want to make sure we are only getting videos relavent to the search back. Youtube for some reason throws so much bs in their default search results
// like "For You" and "People also Watched", which is almost never relevant to the actual search. To fix this just include the param EgIQAQ%3D%3D in the body of the request.
// This way it should only return a list of videos that are relevant to the actual search, including past completed broadcasts and excluding active livestream (which we want).
// Also, don't try to get playlists from the default search, we want to get those from the YTMusic API so we get those nice 2x2 album art thumbnails
// Brand new problems. With the standard YT Video search there is no way to determine if the channel that uploaded it is an artist or just an uploader channel.
// Additionally, ytmusic appears to try to filter results for videos that are music oriented. This does not always work perfectly, especially if you search for something
// inherently non-musical, but it means that generally results are more likely to be music related than with the standard YT search.
// Y'know what, I'm just not going to worry about this now.
const parseSongAndVideoCardShelfRenderer = (card: InnerTube.Search.SongMusicCardShelfRenderer | InnerTube.Search.VideoMusicCardShelfRenderer): Song => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const id = card.title.runs[0].navigationEndpoint.watchEndpoint.videoId
const name = card.title.runs[0].text
const isVideo = card.title.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const duration = timestampToSeconds(card.subtitle.runs.find((run) => /^(\d{1,}:\d{2}:\d{2}|\d{1,2}:\d{2})$/.test(run.text))!.text)
const thumbnailUrl = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Song['artists'], album: Song['album'], uploader: Song['uploader']
card.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
album = runData
break
case 'MUSIC_PAGE_TYPE_ARTIST':
artists ? artists.push(runData) : (artists = [runData])
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
uploader = runData
break
}
})
return { connection, id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo }
}
// Returns null if the video is not playable or if it is an Episode (not currently supported)
// ? The videos filter for YTMusic search only returns sddefault images at most. Might just want to scrape the id an then use getSongs()
const parseSongAndVideoResponsiveListItemRenderer = (
item: InnerTube.Search.SongMusicResponsiveListItemRenderer | InnerTube.Search.VideoMusicResponsiveListItemRenderer | InnerTube.Search.EpisodeMusicResponsiveListItemRenderer,
): Song | null => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const col1 = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0]
const col2runs = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs
if (!col1.navigationEndpoint || 'browseEndpoint' in col1.navigationEndpoint) return null
const id = col1.navigationEndpoint.watchEndpoint.videoId
const name = col1.text
const isVideo = col1.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const duration = timestampToSeconds(col2runs.find((run) => /^(\d{1,}:\d{2}:\d{2}|\d{1,2}:\d{2})$/.test(run.text))!.text)
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Song['artists'], album: Song['album'], uploader: Song['uploader']
col2runs.forEach((run) => {
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
album = runData
break
case 'MUSIC_PAGE_TYPE_ARTIST':
artists ? artists.push(runData) : (artists = [runData])
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
uploader = runData
break
}
})
return { connection, id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo }
}
const parseAlbumCardShelfRenderer = (card: InnerTube.Search.AlbumMusicCardShelfRenderer): Album => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const id = card.title.runs[0].navigationEndpoint.browseEndpoint.browseId
const name = card.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = 'Various Artists',
releaseYear: string | undefined
card.subtitle.runs.forEach((run) => {
if (run.navigationEndpoint) {
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
} else if (/^\d{4}$/.test(run.text)) {
releaseYear = run.text
}
})
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear }
}
const parseArtistCardShelfRenderer = (card: InnerTube.Search.ArtistMusicCardShelfRenderer): Artist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = card.title.runs[0].navigationEndpoint.browseEndpoint.browseId
const name = card.title.runs[0].text
const profilePicture = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture }
}
const parseAlbumResponsiveListItemRenderer = (item: InnerTube.Search.AlbumMusicResponsiveListItemRenderer): Album => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = 'Various Artists',
releaseYear: Album['releaseYear']
item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => {
if (run.navigationEndpoint) {
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
} else if (/^\d{4}$/.test(run.text)) {
releaseYear = run.text
}
})
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear }
}
const parseArtistResponsiveListItemRenderer = (item: InnerTube.Search.ArtistMusicResponsiveListItemRenderer): Artist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture }
}
const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => {
if (!run.navigationEndpoint) return
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy }
}
const contents = searchResponses.map((response) => response.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents).flat()
const cardSections = contents.filter((section) => 'musicCardShelfRenderer' in section)
const shelveSections = contents.filter((section) => 'musicShelfRenderer' in section)
const extractedItems: (Song | Album | Artist | Playlist)[] = []
for (const section of cardSections) {
if ('watchEndpoint' in section.musicCardShelfRenderer.title.runs[0].navigationEndpoint) {
const card = section.musicCardShelfRenderer as InnerTube.Search.SongMusicCardShelfRenderer | InnerTube.Search.VideoMusicCardShelfRenderer
extractedItems.push(parseSongAndVideoCardShelfRenderer(card))
if (!('contents' in card && card.contents)) continue
const playableContents = card.contents.filter((item) => 'musicResponsiveListItemRenderer' in item)
const contentSongs = playableContents.map((item) => parseSongAndVideoResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)).filter((song) => song !== null)
extractedItems.push(...contentSongs)
} else {
const sectionType = section.musicCardShelfRenderer.title.runs[0].navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (sectionType === 'MUSIC_PAGE_TYPE_ALBUM') {
const card = section.musicCardShelfRenderer as InnerTube.Search.AlbumMusicCardShelfRenderer
extractedItems.push(parseAlbumCardShelfRenderer(card))
} else {
const card = section.musicCardShelfRenderer as InnerTube.Search.ArtistMusicCardShelfRenderer
card.contents.forEach((content) => {
const song = parseSongAndVideoResponsiveListItemRenderer(content.musicResponsiveListItemRenderer)
if (song) extractedItems.push(song)
})
extractedItems.push(parseArtistCardShelfRenderer(card))
}
}
}
for (const section of shelveSections) {
switch (section.musicShelfRenderer.title.runs[0].text) {
case 'Songs':
case 'Videos':
const songShelf = section.musicShelfRenderer as InnerTube.Search.SongsMusicShelfRenderer | InnerTube.Search.VideosMusicShelfRenderer
const songs = songShelf.contents.map((item) => parseSongAndVideoResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)).filter((song) => song !== null)
extractedItems.push(...songs)
break
case 'Albums':
const albumShelf = section.musicShelfRenderer as InnerTube.Search.AlbumsMusicShelfRenderer
const albums = albumShelf.contents.map((item) => parseAlbumResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
extractedItems.push(...albums)
break
case 'Artists':
const artistShelf = section.musicShelfRenderer as InnerTube.Search.ArtistsMusicShelfRenderer
const artists = artistShelf.contents.map((item) => parseArtistResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
extractedItems.push(...artists)
break
case 'Community playlists':
const playlistShelf = section.musicShelfRenderer as InnerTube.Search.CommunityPlaylistsMusicShelfRenderer
const playlists = playlistShelf.contents.map((item) => parseCommunityPlaylistResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
extractedItems.push(...playlists)
break
}
}
return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T))
}
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
const parseAlbumMusicTwoRowItemRenderer = (item: InnerTube.Home.AlbumMusicTwoRowItemRenderer): Album => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = 'Various Artists'
item.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
})
return { connection, id, name, type: 'album', thumbnailUrl, artists }
}
const parseArtistMusicTwoRowItemRenderer = (item: InnerTube.Home.ArtistMusicTwoRowItemRenderer): Artist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.title.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture }
}
const parsePlaylistMusicTwoRowItemRenderer = (item: InnerTube.Home.PlaylistMusicTwoRowItemRenderer): Playlist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy }
}
// Returns the ids of songs in place of Song objects becasue full details need to be fetched with getSongs()
const parseMusicCarouselShelfRenderer = (carousel: InnerTube.Home.MusicCarouselShelfRenderer): (string | Album | Artist | Playlist)[] => {
const results: (string | Album | Artist | Playlist)[] = []
for (const item of carousel.contents) {
if ('musicMultiRowListItemRenderer' in item) continue
if ('musicResponsiveListItemRenderer' in item) {
results.push(item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId)
continue
}
const pageType =
'watchEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint
? item.musicTwoRowItemRenderer.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
: item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
switch (pageType) {
case 'MUSIC_VIDEO_TYPE_ATV':
case 'MUSIC_VIDEO_TYPE_OMV':
case 'MUSIC_VIDEO_TYPE_UGC':
case 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC':
const songItem = item.musicTwoRowItemRenderer as InnerTube.Home.SongMusicTwoRowItemRenderer | InnerTube.Home.VideoMusicTwoRowItemRenderer
results.push(songItem.navigationEndpoint.watchEndpoint.videoId)
break
case 'MUSIC_PAGE_TYPE_ALBUM':
const albumItem = item.musicTwoRowItemRenderer as InnerTube.Home.AlbumMusicTwoRowItemRenderer
results.push(parseAlbumMusicTwoRowItemRenderer(albumItem))
break
case 'MUSIC_PAGE_TYPE_ARTIST':
const artistItem = item.musicTwoRowItemRenderer as InnerTube.Home.ArtistMusicTwoRowItemRenderer
results.push(parseArtistMusicTwoRowItemRenderer(artistItem))
break
case 'MUSIC_PAGE_TYPE_PLAYLIST':
const playlistItem = item.musicTwoRowItemRenderer as InnerTube.Home.PlaylistMusicTwoRowItemRenderer
results.push(parsePlaylistMusicTwoRowItemRenderer(playlistItem))
break
}
}
return results
}
const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json<InnerTube.Home.Response>()
const MAX_RECOMMENDATIONS = 20 // Temporary Implementation
const goodSections = ['Listen again', 'Recommended albums', 'From your library', 'Recommended music videos', 'Forgotten favorites', 'Quick picks', 'Long listening']
const contents = response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
.filter((section) => goodSections.includes(section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text))
.map((section) => parseMusicCarouselShelfRenderer(section.musicCarouselShelfRenderer))
.flat()
let continuation = response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation && contents.length < MAX_RECOMMENDATIONS) {
const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Home.ContinuationResponse>()
const continuationContents = continuationResponse.continuationContents.sectionListContinuation.contents
.filter((section) => goodSections.includes(section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text))
.map((section) => parseMusicCarouselShelfRenderer(section.musicCarouselShelfRenderer))
.flat()
contents.push(...continuationContents)
continuation = continuationResponse.continuationContents.sectionListContinuation.continuations?.[0].nextContinuationData.continuation
}
let songsIndex = 0
const songs = await this.getSongs(contents.filter((item) => typeof item === 'string'))
return Array.from(contents, (item) => (typeof item === 'string' ? songs[songsIndex++] : item))
}
public async getAudioStream(id: string, headers: Headers) {
if (!isValidVideoId(id)) throw TypeError('Invalid youtube video Id')
// ? In the future, may want to implement the TVHTML5_SIMPLY_EMBEDDED_PLAYER client method both in order to bypass age-restrictions and just to serve as a fallback
// ? However this has the downsides of being slower and (I think) requiring the user's cookies if the video is premium exclusive.
// ? Ideally, I want to avoid having to mess with a user's cookies at all costs because:
// ? a) It's another security risk
// ? b) A user would have to manually copy them over, which is about as user friendly as a kick to the face
// ? c) Cookies get updated with every request, meaning the db would get hit more frequently, and it's just another thing to maintain
// ? Ulimately though, I may have to implment cookie support anyway dependeding on how youtube tracks a user's watch history and prefrences
// * MASSIVE props and credit to Oleksii Holub for documenting the android client method of player fetching (See refrences at bottom).
// * Go support him and go support Ukraine (he's Ukrainian)
const playerResponse = await this.api.v1.ANDROID_TESTSUITE('player', { json: { videoId: id } }).json<InnerTube.Player.PlayerResponse>()
const formats = playerResponse.streamingData.formats?.concat(playerResponse.streamingData.adaptiveFormats ?? [])
const audioOnlyFormats = formats?.filter(
(format): format is HasDefinedProperty<InnerTube.Player.Format, 'url' | 'audioQuality'> =>
format.qualityLabel === undefined &&
format.audioQuality !== undefined &&
format.url !== undefined &&
!/\bsource[/=]yt_live_broadcast\b/.test(format.url) && // Filters out live broadcasts
!/\/manifest\/hls_(variant|playlist)\//.test(format.url) && // Filters out HLS streams (Might not be applicable to the ANDROID_TESTSUITE client)
!/\/manifest\/dash\//.test(format.url), // Filters out DashMPD streams (Might not be applicable to the ANDROID_TESTSUITE client)
// ? For each of the three above filters, I may want to look into how to support them.
// ? Especially live streams, being able to support those live music stream channels seems like a necessary feature.
// ? HLS and DashMPD I *think* are more efficient so it would be nice to support those too, if applicable.
)
if (!audioOnlyFormats || audioOnlyFormats.length === 0) throw Error(`No valid audio formats returned for song ${id} of connection ${this.id}`)
const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current))
return fetch(hqAudioFormat.url, { headers, keepalive: true })
}
/**
* @param id The browseId of the album
*/
public async getAlbum(id: string): Promise<Album> {
const albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json<InnerTube.Album.AlbumResponse>()
const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const name = header.title.runs[0].text,
thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
const artistMap = new Map<string, { name: string; profilePicture?: string }>()
header.straplineTextOne.runs.forEach((run, index) => {
if (run.navigationEndpoint) {
const profilePicture = index === 0 && header.straplineThumbnail ? extractLargestThumbnailUrl(header.straplineThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined
artistMap.set(run.navigationEndpoint.browseEndpoint.browseId, { name: run.text, profilePicture })
}
})
const artists: Album['artists'] = artistMap.size > 0 ? Array.from(artistMap, (artist) => ({ id: artist[0], name: artist[1].name, profilePicture: artist[1].profilePicture })) : 'Various Artists'
const releaseYear = header.subtitle.runs.at(-1)?.text!
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
}
/**
* @param id The browseId of the album
*/
public async getAlbumItems(id: string): Promise<Song[]> {
const albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json<InnerTube.Album.AlbumResponse>()
const contents = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicShelfRenderer.contents
let continuation = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Album.ContinuationResponse>()
contents.push(...continuationResponse.continuationContents.musicShelfRenderer.contents)
continuation = continuationResponse.continuationContents.musicShelfRenderer.continuations?.[0].nextContinuationData.continuation
}
const playableIds = contents.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId)
return this.getSongs(playableIds)
}
/**
* @param id The id of the playlist (not the browseId!).
*/
public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>()
const sectionContent = playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
const header =
'musicEditablePlaylistDetailHeaderRenderer' in sectionContent ? sectionContent.musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer : sectionContent.musicResponsiveHeaderRenderer
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const name = header.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
const createdBy: Playlist['createdBy'] =
header.straplineTextOne.runs[0].navigationEndpoint?.browseEndpoint.browseId !== undefined
? {
id: header.straplineTextOne.runs[0].navigationEndpoint.browseEndpoint.browseId,
name: header.straplineTextOne.runs[0].text,
profilePicture: header.straplineThumbnail ? extractLargestThumbnailUrl(header.straplineThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined,
}
: undefined
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
}
/**
* @param id The id of the playlist (not the browseId!).
* @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
* @param limit The maximum number of playlist items to return
*/
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise<Song[]> {
const startIndex = options?.startIndex ?? 0,
limit = options?.limit ?? Infinity
const playlistItemSearchParams = new URLSearchParams({
playlistId: id,
maxResults: '50',
part: 'snippet,contentDetails,status',
})
const playableItems: YouTubeDataApi.PlaylistItems.Item<'snippet' | 'contentDetails' | 'status'>[] = []
while (playableItems.length < startIndex + limit) {
const itemsResponse = await this.api.v3(`playlistItems?${playlistItemSearchParams.toString()}`).json<YouTubeDataApi.PlaylistItems.Response<'snippet' | 'contentDetails' | 'status'>>()
playableItems.push(...itemsResponse.items.filter((item) => item.status.privacyStatus === 'public' || item.snippet.videoOwnerChannelId === item.snippet.channelId))
if (!itemsResponse.nextPageToken) break // Reached the end of the playlist, retrieved all items
playlistItemSearchParams.set('pageToken', itemsResponse.nextPageToken)
}
const slicedItems = playableItems.slice(startIndex, startIndex + limit) // Removes over-fetch
const releaseDateMap = new Map<string, string>()
slicedItems.forEach((item) =>
releaseDateMap.set(item.contentDetails.videoId, new Date(item.snippet.description.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? item.contentDetails.videoPublishedAt).toISOString()),
)
const songs = await this.getSongs(releaseDateMap.keys())
songs.forEach((song) => (song.releaseDate = releaseDateMap.get(song.id)))
return songs
}
/**
* @param {Iterable<string>} ids An iterable of youtube video ids. Duplicate ids will be filtered out
* @returns {Promise<Song[]>} An array of Songs. Unavailable songs/videos will be filtered out.
*/
public async getSongs(ids: Iterable<string>): Promise<Song[]> {
const uniqueIds = new Set(ids)
if (uniqueIds.size === 0) return []
const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json<InnerTube.Queue.Response>()
const items = response.queueDatas
.map((item) => {
// If song has both an ATV 'counterpart' and video, this will chose whichever matches the id provided in the request
if ('playlistPanelVideoRenderer' in item.content) return item.content.playlistPanelVideoRenderer
const primaryRenderer = item.content.playlistPanelVideoWrapperRenderer.primaryRenderer.playlistPanelVideoRenderer
if (uniqueIds.has(primaryRenderer.videoId)) return primaryRenderer
return item.content.playlistPanelVideoWrapperRenderer.counterpart[0].counterpartRenderer.playlistPanelVideoRenderer
})
.filter((item) => 'title' in item) // TODO: Add indication that some results were filtered out
return items.map((item) => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const id = item.videoId
const name = item.title.runs[0].text
const duration = timestampToSeconds(item.lengthText.runs[0].text)
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.thumbnails)
const artists: Song['artists'] = []
let album: Song['album']
let uploader: Song['uploader']
item.longBylineText.runs.forEach((run) => {
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runDetails = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
album = runDetails
} else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
artists.push(runDetails)
} else {
uploader = runDetails
}
})
const isVideo = item.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
return { connection, id, name, type: 'song', duration, thumbnailUrl, artists: artists.length > 0 ? artists : undefined, album, uploader, isVideo } satisfies Song
})
}
}
class YouTubeMusicLibrary {
private readonly api: API
constructor(api: API) {
this.api = api
}
public async albums(): Promise<Album[]> {
const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json<InnerTube.Library.AlbumResponse>()
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
return items.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = []
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (run.text === 'Various Artists') return (artists = 'Various Artists')
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
})
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
})
}
public async artists(): Promise<Artist[]> {
const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
return contents.map((item) => {
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
})
}
public async playlists(): Promise<Playlist[]> {
const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json<InnerTube.Library.PlaylistResponse>()
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const playlists = items.filter(
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
)
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
return playlists.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
})
}
}
class API {
public readonly connectionId: string
private currentAccessToken: string
private readonly refreshToken: string
private expiry: number
public readonly v1: {
WEB_REMIX: KyInstance
ANDROID_TESTSUITE: KyInstance
}
public readonly v3: KyInstance
constructor(connectionId: string, accessToken: string, refreshToken: string, expiry: number) {
this.connectionId = connectionId
this.currentAccessToken = accessToken
this.refreshToken = refreshToken
this.expiry = expiry
const authHook = async (request: Request) => request.headers.set('authorization', `Bearer ${await this.accessToken}`)
const baseV1 = ky.create({
prefixUrl: 'https://music.youtube.com/youtubei/v1/',
method: 'post',
hooks: { beforeRequest: [authHook] },
})
const WEB_REMIX = baseV1.extend({
json: {
context: {
client: {
clientName: 'WEB_REMIX',
get clientVersion() {
const currentDate = new Date()
const year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
return `1.${year + month + day}.01.00`
},
},
},
},
})
const ANDROID_TESTSUITE = baseV1.extend({
json: {
context: {
client: {
clientName: 'ANDROID_TESTSUITE',
clientVersion: '1.9',
},
},
},
})
this.v1 = { WEB_REMIX, ANDROID_TESTSUITE }
this.v3 = ky.create({
prefixUrl: 'https://www.googleapis.com/youtube/v3/',
hooks: { beforeRequest: [authHook] },
})
}
private accessTokenRefreshRequest: Promise<string> | null = null
private get accessToken() {
const refreshAccessToken = async () => {
const refreshDetails = {
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET,
refresh_token: this.refreshToken,
grant_type: 'refresh_token',
}
const { access_token, expires_in } = await ky.post('https://oauth2.googleapis.com/token', { json: refreshDetails, retry: 3 }).json<{ access_token: string; expires_in: number }>()
const expiry = Date.now() + expires_in * 1000
return { accessToken: access_token, expiry }
}
// ? Maybe build in a buffer to prevent a token expiring while a request is in flight
if (this.expiry >= Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
this.accessTokenRefreshRequest = refreshAccessToken()
.then(async ({ accessToken, expiry }) => {
await DB.connections.where('id', this.connectionId).update({ accessToken, expiry })
this.currentAccessToken = accessToken
this.expiry = expiry
this.accessTokenRefreshRequest = null
return accessToken
})
.catch((error: Error) => {
this.accessTokenRefreshRequest = null
throw error
})
return this.accessTokenRefreshRequest
}
}
/**
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
* @returns The duration of the timestamp in seconds
*/
function secondsFromISO8601(duration: string): number {
const iso8601DurationRegex = /P(?:([.,\d]+)D)?T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?/ // Credit: https://stackoverflow.com/users/1195273/crush
const result = iso8601DurationRegex.exec(duration)
const days = result?.[1] ?? 0,
hours = result?.[2] ?? 0,
minutes = result?.[3] ?? 0,
seconds = result?.[4] ?? 0
return Number(seconds) + Number(minutes) * 60 + Number(hours) * 3600 + Number(days) * 86400
}
/** Remove YouTube's fake query parameters from their thumbnail urls returning the base url for as needed modification.
* Valid URL origins:
* - https://lh3.googleusercontent.com
* - https://yt3.googleusercontent.com
* - https://yt3.ggpht.com
* - https://music.youtube.com
* - https://www.gstatic.com - Static images (e.g. a placeholder artist profile picture)
* - https://i.ytimg.com - Video Thumbnails
*
* NOTE:
* https://i.ytimg.com corresponds to videos, which follow the mqdefault...maxres resolutions scale. It is generally bad practice to use these as there is no way to scale them with query params, and there is no way to tell if a maxres.jpg exists or not.
* It is generally best practice to not directly scrape these video thumbnails directly from youtube and insted get the highest res from the v3 api.
* However there a few instances in which we want to scrape a thumbail directly from the webapp (e.g. Playlist thumbanils) so it still remains valid.
*/
function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string {
const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url
if (!URL.canParse(bestThumbnailURL)) throw Error('Invalid thumbnail url')
switch (new URL(bestThumbnailURL).origin) {
case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com':
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('='))
case 'https://music.youtube.com':
return bestThumbnailURL
case 'https://www.gstatic.com':
case 'https://i.ytimg.com':
const queryParamStartIndex = bestThumbnailURL.indexOf('?')
return queryParamStartIndex > 0 ? bestThumbnailURL.slice(0, queryParamStartIndex) : bestThumbnailURL
default:
console.error('Tried to clean invalid url: ' + bestThumbnailURL)
throw Error('Invalid thumbnail url origin')
}
}
/**
* @param timestamp A string in the format Hours:Minutes:Seconds (Standard Timestamp format on YouTube)
* @returns The total duration of that timestamp in seconds
*/
function timestampToSeconds(timestamp: string): number {
return timestamp
.split(':')
.reverse()
.reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0)
}
function isValidVideoId(id: string): boolean {
return /^[a-zA-Z0-9-_]{11}$/.test(id)
}
// ? Helpfull Docummentation:
// ? - Making requests to the youtube player: https://tyrrrz.me/blog/reverse-engineering-youtube-revisited (Oleksii Holub, https://github.com/Tyrrrz)
// ? - YouTube API Clients: https://github.com/zerodytrash/YouTube-Internal-Clients (https://github.com/zerodytrash)
// ? Video Test ids:
// ? - DJ Sharpnel Blue Army full ver: iyL0zueK4CY (Standard video; 144p, 240p)
// ? - HELLOHELL: p0qace56glE (Music video type ATV; Premium Exclusive)
// ? - The Stampy Channel - Endless Episodes - 🔴 Rebroadcast: S8s3eRBPCX0 (Live stream; 144p, 240p, 360p, 480p, 720p, 1080p)
// * Thoughs about how to handle VIDEO:
// The isVideo property of Song Objects pertains to whether that specific song entity is a video or auto-generated song.
// It says nothing about whehter or not that song has a video or auto-generated counterpart. Because in many situations
// it is not possible to identify if a scraped song even has a video or auto-generated counterpart, I think it is not a good
// approach to try to store that information in the song object. I need to find a simple way to identify which versions a
// song has though. Ideally that information is known before the song gets played.
+14 -10
View File
@@ -1,10 +1,14 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg"
},
"youtube-music": {
"displayName": "YouTube Music",
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
}
}
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg",
"primaryColor": "--jellyfin-blue"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg",
"primaryColor": "--youtube-red"
}
}
-139
View File
@@ -1,139 +0,0 @@
import { writable, readable, readonly, type Writable, type Readable } from 'svelte/store'
import type { AlertType } from '$lib/components/util/alert.svelte'
export const pageWidth: Writable<number> = writable()
export const newestAlert: Writable<[AlertType, string]> = writable()
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
export const itemDisplayState: Writable<'list' | 'grid'> = writable('grid')
function fisherYatesShuffle<T>(items: T[]) {
for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) {
let randomIndex = Math.floor(Math.random() * (currentIndex + 1))
;[items[currentIndex], items[randomIndex]] = [items[randomIndex], items[currentIndex]]
}
return items
}
class Queue {
private currentPosition: number // -1 means no song is playing
private originalSongs: Song[]
private currentSongs: Song[]
private shuffled: boolean
constructor() {
this.currentPosition = -1
this.originalSongs = []
this.currentSongs = []
this.shuffled = false
}
private updateQueue() {
writableQueue.set(this)
// TODO: Implement Queue Saver
}
get current() {
if (this.currentSongs.length === 0) return null
if (this.currentPosition === -1) this.currentPosition = 0
return this.currentSongs[this.currentPosition]
}
get upNext() {
if (this.currentSongs.length === 0 || this.currentPosition >= this.currentSongs.length) return null
return this.currentSongs[this.currentPosition + 1]
}
get list() {
return this.currentSongs
}
get isShuffled() {
return this.shuffled
}
/** Sets the currently playing song to the song provided as long as it is in the current playlist */
public setCurrent(newSong: Song) {
const queuePosition = this.currentSongs.findIndex((song) => song === newSong)
if (queuePosition < 0) return
this.currentPosition = queuePosition
this.updateQueue()
}
/** Shuffles all songs in the queue after the currently playing song */
public shuffle() {
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))
this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs)
this.shuffled = true
this.updateQueue()
}
/** Restores the queue to its original ordered state, while maintaining whatever song is currently playing */
public reorder() {
const originalPosition = this.originalSongs.findIndex((song) => song === this.currentSongs[this.currentPosition])
this.currentSongs = [...this.originalSongs]
this.currentPosition = originalPosition
this.shuffled = false
this.updateQueue()
}
/** Re-orders the queue if shuffled, shuffles if not */
public toggleShuffle() {
this.shuffled ? this.reorder() : this.shuffle()
}
/** Starts the next song */
public next() {
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
this.currentPosition += 1
this.updateQueue()
}
/** Plays the previous song */
public previous() {
if (this.currentSongs.length === 0 || this.currentPosition <= 0) return
this.currentPosition -= 1
this.updateQueue()
}
/** Add songs to the end of the queue */
public enqueue(...songs: Song[]) {
this.originalSongs.push(...songs)
this.currentSongs.push(...songs)
this.updateQueue()
}
/**
* @param songs An ordered array of Songs
* @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified
*/
public setQueue(songs: Song[], shuffled?: boolean) {
if (songs.length === 0) return // Should not set a queue with no songs, use clear()
this.originalSongs = songs
this.currentSongs = shuffled ? fisherYatesShuffle(songs) : songs
this.currentPosition = 0
this.shuffled = shuffled ?? false
this.updateQueue()
}
/** Clears all items from the queue */
public clear() {
this.currentPosition = -1
this.originalSongs = []
this.currentSongs = []
this.updateQueue()
}
}
const writableQueue: Writable<Queue> = writable(new Queue())
export const queue: Readable<Queue> = readonly(writableQueue)
+23
View File
@@ -0,0 +1,23 @@
export const shake = (strength = 1) => {
return {
transform: [
`translateX(-${strength}px)`,
`translateX(${strength * 2}px)`,
`translateX(-${strength * 4}px)`,
`translateX(${strength * 4}px)`,
`translateX(-${strength * 4}px)`,
`translateX(${strength * 4}px)`,
`translateX(-${strength * 4}px)`,
`translateX(${strength * 2}px)`,
`translateX(-${strength}px)`,
],
offset: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
}
}
export const spin = (rotations = 1) => {
return [
{ rotate: '0deg', easing: 'ease-in-out' },
{ rotate: `${rotations * 360}deg`, easing: 'ease-in-out' },
]
}
+9
View File
@@ -0,0 +1,9 @@
import { writable } from 'svelte/store'
export const pageWidth = writable(null)
export const newestAlert = writable([null, null])
export const currentlyPlaying = writable(null)
export const backgroundImage = writable(null)
+116
View File
@@ -0,0 +1,116 @@
import Joi from 'joi'
export const getVolume = () => {
const currentVolume = localStorage.getItem('volume')
if (currentVolume) return Number(currentVolume)
const defaultVolume = 100
localStorage.setItem('volume', defaultVolume)
return defaultVolume
}
export const setVolume = (volume) => {
if (Number.isFinite(volume)) localStorage.setItem('volume', Math.round(volume))
}
export const formatDuration = (timeMilliseconds) => {
const seconds = Math.floor((timeMilliseconds / 1000) % 60)
const minutes = Math.floor((timeMilliseconds / 1000 / 60) % 60)
return [minutes.toString(), seconds.toString().padStart(2, '0')].join(':')
}
export class JellyfinUtils {
static #AUDIO_PRESETS = {
default: {
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: REMEMBER TO ADD THIS TO THE END,
},
}
static mediaItemFactory = (itemData, connectionData) => {
const generalItemSchema = Joi.object({
ServerId: Joi.string().required(),
Type: Joi.string().required(),
}).unknown(true)
const generalItemValidation = generalItemSchema.validate(itemData)
if (generalItemValidation.error) throw new Error(generalItemValidation.error.message)
switch (itemData.Type) {
case 'Audio':
return this.songFactory(itemData, connectionData)
case 'MusicAlbum':
break
}
}
static songFactory = (songData, connectionData) => {
const { id, serviceType, serviceUserId, serviceUrl } = connectionData
const songSchema = Joi.object({
Name: Joi.string().required(),
Id: Joi.string().required(),
RunTimeTicks: Joi.number().required(),
}).unknown(true)
const songValidation = songSchema.validate(songData)
if (songValidation.error) throw new Error(songValidation.error.message)
const artistData = songData?.ArtistItems
? Array.from(songData.ArtistItems, (artist) => {
return { name: artist.Name, id: artist.Id }
})
: null
const albumData = songData?.AlbumId
? {
name: songData.Album,
id: songData.AlbumId,
artists: songData.AlbumArtists,
image: songData?.AlbumPrimaryImageTag ? new URL(`Items/${songData.AlbumId}/Images/Primary`, serviceUrl).href : null,
}
: null
const imageSource = songData?.ImageTags?.Primary ? new URL(`Items/${songData.Id}/Images/Primary`, serviceUrl).href : albumData?.image
const audioSearchParams = new URLSearchParams(this.#AUDIO_PRESETS.default)
audioSearchParams.append('userId', serviceUserId)
const audoSource = new URL(`Audio/${songData.Id}/universal?${audioSearchParams.toString()}`, serviceUrl).href
return {
connectionId: id,
serviceType,
mediaType: 'song',
name: songData.Name,
id: songData.Id,
duration: Math.floor(songData.RunTimeTicks / 10000), // <-- Converts 'ticks' (whatever that means) to milliseconds, a sane unit of measure
artists: artistData,
album: albumData,
image: imageSource,
audio: audoSource,
video: null,
releaseDate: songData?.ProductionYear,
}
}
static getLocalDeviceUUID = () => {
const existingUUID = localStorage.getItem('lazuliDeviceUUID')
if (!existingUUID) {
const newUUID = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
localStorage.setItem('lazuliDeviceUUID', newUUID)
return newUUID
}
return existingUUID
}
}
export class YouTubeMusicUtils {
static mediaItemFactory = () => {}
}
+143 -37
View File
@@ -1,49 +1,155 @@
<script lang="ts">
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
import Navbar from '$lib/components/util/navbar.svelte'
import Sidebar from '$lib/components/util/sidebar.svelte'
import { queue } from '$lib/stores'
<script>
import MiniPlayer from '$lib/components/media/miniPlayer.svelte'
import { fly, fade } from 'svelte/transition'
import { goto } from '$app/navigation'
import { pageWidth } from '$lib/utils/stores.js'
// I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout
let sidebar: Sidebar
export let data
$: currentlyPlaying = $queue.current
$: shuffled = $queue.isShuffled
const contentTabs = {
'/': {
header: 'Home',
icon: 'fa-solid fa-house',
},
'/account': {
header: data.username,
icon: 'fa-solid fa-user',
},
'/search': {
header: 'Search',
icon: 'fa-solid fa-search',
},
'/library': {
header: 'Libray',
icon: 'fa-solid fa-bars-staggered',
},
}
let playerWidth: number
const pageTransitionTime = 200
let previousPage = data.url
let direction = 1
$: calculateDirection(data.url)
const calculateDirection = (newPage) => {
const contentLinks = Object.keys(contentTabs)
const newPageIndex = contentLinks.indexOf(newPage)
const previousPageIndex = contentLinks.indexOf(previousPage)
if (newPageIndex > previousPageIndex) {
direction = 1
} else {
direction = -1
}
previousPage = data.url
}
let activeTab, indicatorBar, tabList
$: calculateBar(activeTab)
const calculateBar = (activeTab) => {
if (activeTab && indicatorBar && tabList) {
if ($pageWidth >= 768) {
const listRect = tabList.getBoundingClientRect()
const tabRec = activeTab.getBoundingClientRect()
if (direction === 1) {
indicatorBar.style.bottom = `${listRect.bottom - tabRec.bottom}px`
setTimeout(() => (indicatorBar.style.top = `${tabRec.top - listRect.top}px`), pageTransitionTime)
} else {
indicatorBar.style.top = `${tabRec.top - listRect.top}px`
setTimeout(() => (indicatorBar.style.bottom = `${listRect.bottom - tabRec.bottom}px`), pageTransitionTime)
}
} else {
const listRect = tabList.getBoundingClientRect()
const tabRec = activeTab.getBoundingClientRect()
if (direction === 1) {
indicatorBar.style.right = `${listRect.right - tabRec.right}px`
setTimeout(() => (indicatorBar.style.left = `${tabRec.left - listRect.left}px`), pageTransitionTime)
} else {
indicatorBar.style.left = `${tabRec.left - listRect.left}px`
setTimeout(() => (indicatorBar.style.right = `${listRect.right - tabRec.right}px`), pageTransitionTime)
}
}
}
}
</script>
<main id="grid-wrapper" class="relative h-full">
<Navbar on:opensidebar={sidebar.open} />
<Sidebar bind:this={sidebar} />
<section id="content-wrapper" class="overflow-y-scroll no-scrollbar">
<div class="my-8">
<slot />
</div>
{#if currentlyPlaying}
<div bind:clientWidth={playerWidth} class="sticky {playerWidth > 800 ? 'bottom-0' : 'bottom-3 mx-3'} transition-all">
<MediaPlayer
mediaItem={currentlyPlaying}
{shuffled}
mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null}
--border-radius={playerWidth > 800 ? '0' : '0.5rem'}
on:stop={() => $queue.clear()}
on:next={() => $queue.next()}
on:previous={() => $queue.previous()}
on:toggleShuffle={() => $queue.toggleShuffle()}
/>
{#if $pageWidth >= 768}
<div id="content-grid" class="h-full overflow-hidden">
<section class="relative mr-10 flex h-full flex-col gap-6 rounded-lg px-3 py-12" bind:this={tabList}>
{#each Object.entries(contentTabs) as [page, tabData]}
{#if data.url === page}
<button bind:this={activeTab} class="pointer-events-none grid aspect-square w-14 place-items-center text-white transition-colors" disabled="true">
<span class="text-xs">
<i class="{tabData.icon} mb-2 text-xl" />
{tabData.header}
</span>
</button>
{:else}
<button class="grid aspect-square w-14 place-items-center text-neutral-400 transition-colors hover:text-lazuli-primary" on:click={() => goto(page)}>
<span class="text-xs">
<i class="{tabData.icon} mb-2 text-xl" />
{tabData.header}
</span>
</button>
{/if}
{/each}
{#if data.url in contentTabs}
<div bind:this={indicatorBar} transition:fade class="absolute left-0 w-[0.2rem] rounded-full bg-white transition-all duration-300 ease-in-out" />
{/if}
</section>
<section class="no-scrollbar relative overflow-y-scroll">
{#key previousPage}
<div in:fly={{ y: -50 * direction, duration: pageTransitionTime, delay: pageTransitionTime }} out:fly={{ y: 50 * direction, duration: pageTransitionTime }} class="absolute w-full pr-[5vw] pt-16">
<slot />
</div>
{/key}
</section>
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
<MiniPlayer displayMode={'horizontal'} />
</footer>
</div>
{:else}
<div class="h-full overflow-hidden">
{#key previousPage}
<section
in:fly={{ x: 200 * direction, duration: pageTransitionTime, delay: pageTransitionTime }}
out:fly={{ x: -200 * direction, duration: pageTransitionTime }}
class="no-scrollbar h-full overflow-y-scroll px-[5vw] pt-16"
>
<slot />
</section>
{/key}
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
<MiniPlayer displayMode={'vertical'} />
<div bind:this={tabList} id="bottom-tab-list" class="relative flex w-full items-center justify-around bg-black">
{#each Object.entries(contentTabs) as [page, tabData]}
{#if data.url === page}
<button bind:this={activeTab} class="pointer-events-none text-white transition-colors" disabled="true">
<i class={tabData.icon} />
</button>
{:else}
<button class="text-neutral-400 transition-colors hover:text-lazuli-primary" on:click={() => goto(page)}>
<i class={tabData.icon} />
</button>
{/if}
{/each}
{#if data.url in contentTabs}
<div bind:this={indicatorBar} transition:fade class="absolute bottom-0 h-1 rounded-full bg-white transition-all duration-300 ease-in-out" />
{/if}
</div>
{/if}
</section>
</main>
</footer>
</div>
{/if}
<style>
#grid-wrapper {
#content-grid {
display: grid;
grid-template-rows: min-content auto;
grid-template-columns: max-content auto;
grid-template-rows: 100%;
}
#content-wrapper {
display: grid;
grid-template-rows: auto min-content;
#bottom-tab-list {
padding: 16px 0px;
font-size: 20px;
line-height: 28px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
export const prerender = false
/** @type {import('./$types').PageServerLoad} */
export const load = async ({ locals, fetch, url }) => {
const recommendationResponse = await fetch(`/api/user/recommendations?userId=${locals.userId}&limit=10`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
const recommendationsData = await recommendationResponse.json()
const { recommendations, errors } = recommendationsData
return {
url: url.pathname,
user: locals.user,
recommendations,
fetchingErrors: errors,
}
}
-10
View File
@@ -1,10 +0,0 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals, fetch }) => {
const getRecommendations = async () =>
fetch(`/api/users/${locals.user.id}/recommendations`)
.then((response) => response.json() as Promise<{ recommendations: (Song | Album | Artist | Playlist)[] }>)
.then((data) => data.recommendations)
return { recommendations: getRecommendations() }
}
+28 -69
View File
@@ -1,72 +1,31 @@
<script lang="ts">
import type { PageData } from './$types'
import Loader from '$lib/components/util/loader.svelte'
import AlbumCard from '$lib/components/media/albumCard.svelte'
import PlaylistCard from '$lib/components/media/playlistCard.svelte'
<script>
import { onMount } from 'svelte'
import { newestAlert } from '$lib/utils/stores.js'
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
export let data: PageData
export let data
onMount(() => {
const logFetchError = (index, errors) => {
if (index >= errors.length) return
const errorMessage = errors[index]
$newestAlert = ['warning', errorMessage]
setTimeout(() => logFetchError((index += 1), errors), 100)
}
logFetchError(0, data.fetchingErrors)
})
</script>
<div id="main" class="grid">
{#await data.recommendations}
<Loader />
{:then recommendations}
<div id="card-wrapper" class="grid w-full gap-4 justify-self-center px-[5%]">
{#each recommendations as mediaItem}
{#if mediaItem.type === 'album'}
<AlbumCard album={mediaItem} />
{:else if mediaItem.type === 'playlist'}
<PlaylistCard playlist={mediaItem} />
{/if}
{/each}
</div>
{/await}
</div>
<style>
@media (min-width: 350px) {
#card-wrapper {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 550px) {
#card-wrapper {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 750px) {
#card-wrapper {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 950px) {
#card-wrapper {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 1150px) {
#card-wrapper {
grid-template-columns: repeat(6, 1fr);
}
}
@media (min-width: 1350px) {
#card-wrapper {
grid-template-columns: repeat(7, 1fr);
}
}
@media (min-width: 1550px) {
#card-wrapper {
grid-template-columns: repeat(8, 1fr);
}
}
@media (min-width: 2200px) {
#card-wrapper {
grid-template-columns: repeat(9, 1fr);
}
}
@media (min-width: 3000px) {
#card-wrapper {
grid-template-columns: repeat(10, 1fr);
}
}
</style>
{#if !data.recommendations && data.fetchingErrors.length === 0}
<main class="flex h-full flex-col items-center justify-center gap-4 text-center">
<h1 class="text-4xl">Let's Add Some Connections</h1>
<p class="text-neutral-400">Click the menu in the top left corner and go to Settings &gt; Connections to link to your accounts</p>
</main>
{:else}
<main id="recommendations-wrapper" class="h-[200vh] w-full">
<ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} />
</main>
{/if}
+30
View File
@@ -0,0 +1,30 @@
<script>
import IconButton from '$lib/components/utility/iconButton.svelte'
export let data
</script>
<main class="flex flex-col gap-8">
<section class="flex items-center justify-between text-xl">
<div class="flex items-center gap-4">
<div class="grid aspect-square h-36 place-items-center rounded-full bg-neutral-800">
<i class="fa-solid fa-user text-6xl text-neutral-400" />
</div>
<div>
<div>{data.username}</div>
<div class="text-base text-neutral-400">Other info about the user</div>
</div>
</div>
<div class="flex h-12 gap-4">
<IconButton>
<i slot="icon" class="fa-solid fa-gear" />
</IconButton>
<IconButton>
<i slot="icon" class="fa-solid fa-right-from-bracket" />
</IconButton>
</div>
</section>
<section>
<div>This is where things like history would go</div>
</section>
</main>
@@ -0,0 +1,11 @@
export async function load({ fetch, params }) {
const albumId = params.id
const response = await fetch(`/api/jellyfin/album?albumId=${albumId}`)
const responseData = await response.json()
return {
id: albumId,
albumItemsData: responseData.albumItems,
albumData: responseData.albumData,
}
}
+56
View File
@@ -0,0 +1,56 @@
<script>
import { fly, slide } from 'svelte/transition'
import { cubicIn, cubicOut } from 'svelte/easing'
import { JellyfinUtils } from '$lib/utils'
import AlbumBg from '$lib/albumBG.svelte'
import Navbar from '$lib/navbar.svelte'
import ListItem from '$lib/listItem.svelte'
import Header from '$lib/header.svelte'
import MediaPlayer from '$lib/mediaPlayer.svelte'
export let data
let albumImg = JellyfinUtils.getImageEnpt(data.id)
let discArray = Array.from({ length: data.albumData?.discCount ? data.albumData.discCount : 1 }, (_, i) => {
return data.albumItemsData.filter((item) => item?.ParentIndexNumber === i + 1 || !item?.ParentIndexNumber)
})
let mediaPlayerOpen = false
let currentMediaPlayerItem
const playSong = (event) => {
currentMediaPlayerItem = event.detail.item
mediaPlayerOpen = true
}
</script>
<div id="main" class="flex h-screen flex-col" in:fly={{ easing: cubicOut, y: 10, duration: 1000, delay: 400 }} out:fly={{ easing: cubicIn, y: -10, duration: 500 }}>
<AlbumBg {albumImg} />
<Navbar />
<div id="layout" class="no-scrollbar relative m-[0_auto] grid w-full max-w-[92vw] grid-cols-1 gap-0 overflow-y-scroll pt-8 md:grid-cols-[1fr_2fr] md:gap-[4%] md:pt-48">
<img class="rounded object-cover" src={albumImg} alt="Jacket" draggable="false" />
<div class="my-12 flex flex-col gap-4 pr-2">
<Header header={data.albumData.name} artists={data.albumData.artists} year={data.albumData.year} length={data.albumData.length} />
<div class="flex flex-col gap-4">
{#each discArray as disc}
<div>
{#if data.albumData.discCount}
<span class="m-3 block font-notoSans text-lg text-neutral-300">DISC {discArray.indexOf(disc) + 1}</span>
{/if}
<div class="flex w-full flex-col items-center divide-y-[1px] divide-[#353535]">
{#each disc as song}
<ListItem item={song} on:startPlayback={playSong} />
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="fixed bottom-0 z-20 w-full">
{#if mediaPlayerOpen}
<div transition:slide={{ duration: 400 }} class="h-screen">
<MediaPlayer currentlyPlaying={currentMediaPlayerItem} playlistItems={data.albumItemsData} on:closeMediaPlayer={() => (mediaPlayerOpen = false)} on:startPlayback={playSong} />
</div>
{/if}
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
export async function load({ url }) {
const { id, service } = Object.fromEntries(url.searchParams)
return {
artistId: id,
connectionId: service,
}
}
+8
View File
@@ -0,0 +1,8 @@
<script>
export let data
</script>
<section>
<div>This is the page for artist: {data.artistId}</div>
<div>From connection: {data.connectionId}</div>
</section>
+1
View File
@@ -0,0 +1 @@
<div>Hello, this is where info about the music would go!</div>
@@ -1,18 +0,0 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ fetch, url }) => {
const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id')
const getAlbum = async () =>
fetch(`/api/connections/${connectionId}/album?id=${id}`)
.then((response) => response.json() as Promise<{ album: Album }>)
.then((data) => data.album)
const getAlbumItems = async () =>
fetch(`/api/connections/${connectionId}/album/${id}/items`)
.then((response) => response.json() as Promise<{ items: Song[] }>)
.then((data) => data.items)
return { albumDetails: Promise.all([getAlbum(), getAlbumItems()]) }
}
@@ -1,34 +0,0 @@
<script lang="ts">
import Loader from '$lib/components/util/loader.svelte'
import type { PageData } from './$types'
export let data: PageData
</script>
<main>
{#await data.albumDetails}
<Loader />
{:then [album, items]}
<section class="flex gap-8">
<img class="h-60" src="/api/remoteImage?url={album.thumbnailUrl}" alt="{album.name} cover art" />
<div>
<div class="text-4xl">{album.name}</div>
{#if album.artists === 'Various Artists'}
<div>Various Artists</div>
{:else}
<div style="font-size: 0;">
{#each album.artists as artist, index}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={album.connection.id}">{artist.name}</a>
{#if index < album.artists.length - 1}
<span class="mr-0.5 text-sm">,</span>
{/if}
{/each}
</div>
{/if}
</div>
</section>
{#each items as item}
<div>{item.name}</div>
{/each}
{/await}
</main>
@@ -1,22 +0,0 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ fetch, url }) => {
const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id')
async function getPlaylist() {
const playlistResponse = (await fetch(`/api/connections/${connectionId}/playlist?id=${id}`, {
credentials: 'include',
}).then((response) => response.json())) as { playlist: Playlist }
return playlistResponse.playlist
}
async function getPlaylistItems() {
const itemsResponse = (await fetch(`/api/connections/${connectionId}/playlist/${id}/items`, {
credentials: 'include',
}).then((response) => response.json())) as { items: Song[] }
return itemsResponse.items
}
return { playlistDetails: Promise.all([getPlaylist(), getPlaylistItems()]) }
}
@@ -1,24 +0,0 @@
<script lang="ts">
import Loader from '$lib/components/util/loader.svelte'
import type { PageData } from './$types'
export let data: PageData
</script>
<main>
{#await data.playlistDetails}
<Loader />
{:then [playlist, items]}
<section class="flex gap-8">
<img class="h-60" src="/api/remoteImage?url={playlist.thumbnailUrl}" alt="{playlist.name} cover art" />
<div>
<div class="text-4xl">{playlist.name}</div>
</div>
</section>
{#each items as item}
<div>{item.name}</div>
{/each}
{:catch}
<div>Failed to fetch playlist</div>
{/await}
</main>
@@ -1 +0,0 @@
<h1>Add subroutes for artist, automatically generated playlists, albums, and songs</h1>
-41
View File
@@ -1,41 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { itemDisplayState } from '$lib/stores'
import { fade } from 'svelte/transition'
import IconButton from '$lib/components/util/iconButton.svelte'
import { page } from '$app/stores'
$: currentPathname = $page.url.pathname
</script>
<main class="py-4">
<nav id="nav-options" class="mb-8 flex h-12 justify-between">
<section class="relative flex h-full gap-4">
<button disabled={/^\/library$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library')}>History</button>
<button disabled={/^\/library\/albums.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/albums')}>Albums</button>
<button disabled={/^\/library\/artists.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/artists')}>Artists</button>
<button disabled={/^\/library\/collection.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/collection')}>My Collection</button>
</section>
<section class="flex h-full justify-self-end">
<IconButton disabled={$itemDisplayState === 'list'} on:click={() => ($itemDisplayState = 'list')}>
<i slot="icon" class="fa-solid fa-list {$itemDisplayState === 'list' ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton disabled={$itemDisplayState === 'grid'} on:click={() => ($itemDisplayState = 'grid')}>
<i slot="icon" class="fa-solid fa-grip {$itemDisplayState === 'grid' ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
</section>
</nav>
{#key currentPathname}
<div in:fade={{ duration: 200, delay: 200 }} out:fade={{ duration: 200 }}>
<slot />
</div>
{/key}
</main>
<style>
button.library-tab[disabled] {
color: var(--lazuli-primary);
border-top: 2px solid var(--lazuli-primary);
background: linear-gradient(to bottom, var(--lazuli-primary) -150%, transparent 50%);
}
</style>
-5
View File
@@ -1,5 +0,0 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
return { user: locals.user }
}
+1 -18
View File
@@ -1,18 +1 @@
<script lang="ts">
import type { PageServerData } from './$types.js'
export let data: PageServerData
async function testRequest() {
const mixData = await fetch(`/api/v1/users/${data.user.id}fkaskdkja/mixes`, {
credentials: 'include',
}).then((response) => response.json())
console.log(mixData)
}
</script>
<div>
<h1>This would be a good place for listen history</h1>
<button on:click={testRequest} class="h-14 w-20 rounded-lg bg-lazuli-primary">Test Request</button>
</div>
<h1>This is where library items will go</h1>
@@ -1,11 +0,0 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ fetch, locals }) => {
const getLibraryAlbums = async () =>
fetch(`/api/users/${locals.user.id}/library/albums`)
.then((response) => response.json() as Promise<{ items: Album[] }>)
.then((data) => data.items)
.catch(() => ({ error: 'Failed to retrieve library albums' }))
return { albums: getLibraryAlbums() }
}
@@ -1,47 +0,0 @@
<script lang="ts">
import type { PageServerData } from './$types'
import { itemDisplayState } from '$lib/stores'
import Loader from '$lib/components/util/loader.svelte'
import AlbumCard from '$lib/components/media/albumCard.svelte'
import ListItem from '$lib/components/media/listItem.svelte'
export let data: PageServerData
</script>
<section>
{#await data.albums}
<Loader />
{:then albums}
{#if 'error' in albums}
<h1>{albums.error}</h1>
{:else if $itemDisplayState === 'list'}
<div class="text-md flex flex-col gap-4">
<!-- .slice is temporary to mimic performance with pagination -->
{#each albums.slice(0, 100) as album}
<ListItem mediaItem={album} />
{/each}
</div>
{:else}
<div id="library-wrapper">
<!-- .slice is temporary to mimic performance with pagination -->
{#each albums as album}
<AlbumCard {album} />
{/each}
</div>
{/if}
{/await}
</section>
<style>
#library-wrapper {
display: grid;
/* gap: 1.5rem; */
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
/* This caps the maxiumn number of columns at 10. Beyond that point the cards will continuously get larger */
@media (min-width: calc(13rem * 10)) {
#library-wrapper {
grid-template-columns: repeat(10, 1fr);
}
}
</style>
-14
View File
@@ -1,14 +0,0 @@
<script lang="ts">
import IconButton from '$lib/components/util/iconButton.svelte'
import IcBaselinePlus from '~icons/ic/baseline-plus'
</script>
<main class="flex flex-wrap justify-center">
<div class="grid aspect-square h-56 place-items-center">
<div class="aspect-square h-16">
<IconButton halo={true}>
<IcBaselinePlus slot="icon" class="text-4xl text-neutral-300" />
</IconButton>
</div>
</div>
</main>
+1
View File
@@ -0,0 +1 @@
<main>Hello this is where playlist go</main>
-13
View File
@@ -1,13 +0,0 @@
import type { PageServerLoad } from '../$types'
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const query = url.searchParams.get('query')
if (query) {
const getSearchResults = async () =>
fetch(`/api/v1/search?query=${query}&userId=${locals.user.id}&types=song,album,artist,playlist`)
.then((response) => response.json() as Promise<{ results: (Song | Album | Artist | Playlist)[] }>)
.then((data) => data.results)
return { searchResults: getSearchResults() }
}
}
+1 -16
View File
@@ -1,16 +1 @@
<script lang="ts">
import type { PageServerData } from './$types'
import MediaCard from '$lib/components/media/mediaCard.svelte'
export let data: PageServerData
</script>
{#if data.searchResults}
{#await data.searchResults then searchResults}
<section class="flex w-full flex-wrap gap-6">
{#each searchResults as searchResult}
<MediaCard mediaItem={searchResult} />
{/each}
</section>
{/await}
{/if}
<h1>Search Page</h1>
-73
View File
@@ -1,73 +0,0 @@
import { fail } from '@sveltejs/kit'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import type { PageServerLoad, Actions } from './$types'
import { DB } from '$lib/server/db'
import { Jellyfin } from '$lib/server/jellyfin'
import { google } from 'googleapis'
import ky from 'ky'
export const load: PageServerLoad = async ({ fetch, locals, url }) => {
const getConnectionInfo = () =>
ky
.get(`api/users/${locals.user.id}/connections`, { fetch, prefixUrl: url.origin })
.json<{ connections: ConnectionInfo[] }>()
.then((response) => response.connections)
.catch(() => ({ error: 'Failed to retrieve connections' }))
return { connections: getConnectionInfo() }
}
export const actions: Actions = {
authenticateJellyfin: async ({ request, fetch, locals, url }) => {
const formData = await request.formData()
const { serverUrl, username, password, deviceId } = Object.fromEntries(formData)
if (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' })
const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch(() => null)
if (!authData) return fail(400, { message: 'Failed to Authenticate' })
const userId = locals.user.id
const serviceUserId = authData.User.Id
const accessToken = authData.AccessToken
const newConnectionId = await DB.connections.insert({ id: DB.uuid(), userId, type: 'jellyfin', serviceUserId, serverUrl: serverUrl.toString(), accessToken }, 'id').then((data) => data[0].id)
const connectionsResponse = await ky.get(`api/connections?id=${newConnectionId}`, { fetch, prefixUrl: url.origin }).json<{ connections: ConnectionInfo[] }>()
const newConnection = connectionsResponse.connections[0]
return { newConnection }
},
youtubeMusicLogin: async ({ request, fetch, locals, url }) => {
const formData = await request.formData()
const { code } = Object.fromEntries(formData)
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
const { access_token, refresh_token, expiry_date } = (await client.getToken(code.toString())).tokens
const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: access_token! })
const userChannel = userChannelResponse.data.items![0]
const userId = locals.user.id
const serviceUserId = userChannel.id!
const newConnectionId = await DB.connections
.insert({ id: DB.uuid(), userId, type: 'youtube-music', serviceUserId, accessToken: access_token!, refreshToken: refresh_token!, expiry: expiry_date! }, 'id')
.then((data) => data[0].id)
const connectionsResponse = await ky.get(`api/connections?id=${newConnectionId}`, { fetch, prefixUrl: url.origin }).json<{ connections: ConnectionInfo[] }>()
const newConnection = connectionsResponse.connections[0]
return { newConnection }
},
deleteConnection: async ({ request }) => {
const formData = await request.formData()
const connectionId = formData.get('connectionId')!.toString()
await DB.connections.where('id', connectionId).del()
return { deletedConnectionId: connectionId }
},
}
-180
View File
@@ -1,180 +0,0 @@
<script lang="ts">
import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation'
import type { LayoutData } from '../$types'
import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js'
import type { SubmitFunction } from '@sveltejs/kit'
import { SvelteComponent, type ComponentType } from 'svelte'
import ConnectionProfile from './connectionProfile.svelte'
import { enhance } from '$app/forms'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import Loader from '$lib/components/util/loader.svelte'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let data: PageServerData & LayoutData
let connections: ConnectionInfo[]
let errorMessage: string
data.connections.then((userConnections) => ('error' in userConnections ? (errorMessage = userConnections.error) : (connections = userConnections)))
function getDeviceUUID(): string {
const existingUUID = localStorage.getItem('deviceUUID')
if (existingUUID) return existingUUID
const newUUID = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
localStorage.setItem('deviceUUID', newUUID)
return newUUID
}
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
const { serverUrl, username, password } = Object.fromEntries(formData)
if (!(serverUrl && username && password)) {
$newestAlert = ['caution', 'All fields must be filled out']
return cancel()
}
if (!URL.canParse(serverUrl.toString())) {
$newestAlert = ['caution', 'Server URL is invalid']
return cancel()
}
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
const deviceId = getDeviceUUID()
formData.append('deviceId', deviceId)
return ({ result }) => {
if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') {
const newConnection = result.data!.newConnection
connections = [...connections, newConnection]
newConnectionModal = null
return ($newestAlert = ['success', `Added Jellyfin`])
}
}
}
const authenticateYouTube: SubmitFunction = async ({ formData, cancel }) => {
const googleLoginProcess = (): Promise<string> => {
return new Promise((resolve) => {
// @ts-ignore (google variable is a global variable imported by html script tag)
const client = google.accounts.oauth2.initCodeClient({
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
scope: 'https://www.googleapis.com/auth/youtube',
ux_mode: 'popup',
callback: (response: any) => {
resolve(response.code)
},
})
client.requestCode()
})
}
const code = await googleLoginProcess()
if (!code) cancel()
formData.append('code', code)
return ({ result }) => {
if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') {
const newConnection = result.data!.newConnection
connections = [...connections, newConnection]
return ($newestAlert = ['success', 'Added Youtube Music'])
}
}
}
const profileActions: SubmitFunction = () => {
return ({ result }) => {
if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') {
const id = result.data!.deletedConnectionId
const indexToDelete = connections.findIndex((connection) => connection.id === id)
const serviceType = connections[indexToDelete].type
connections.splice(indexToDelete, 1)
connections = connections
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
}
}
}
let newConnectionModal: ComponentType<SvelteComponent<{ submitFunction: SubmitFunction }>> | null = null
</script>
<main class="flex flex-col gap-8">
<section class="flex items-center justify-between text-xl">
<div class="flex items-center gap-4">
<div class="grid aspect-square h-36 place-items-center rounded-full bg-neutral-800">
<i class="fa-solid fa-user text-6xl text-neutral-400" />
</div>
<div>
<div>{data.user.username}</div>
<div class="text-base text-neutral-400">Other info about the user</div>
</div>
</div>
<div class="flex h-10 gap-6">
<IconButton on:click={() => goto('/settings')} halo={true}>
<i slot="icon" class="fa-solid fa-gear" />
</IconButton>
<IconButton halo={true}>
<i slot="icon" class="fa-solid fa-right-from-bracket" />
</IconButton>
</div>
</section>
<section>
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<div class="aspect-square h-full p-2">
<ServiceLogo type={'jellyfin'} />
</div>
</button>
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
<button class="add-connection-button h-14 rounded-md">
<div class="aspect-square h-full p-2">
<ServiceLogo type={'youtube-music'} />
</div>
</button>
</form>
</div>
</section>
{#if connections}
<div id="connection-profile-grid" class="grid gap-8">
{#each connections as connectionInfo}
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
{/each}
</div>
{:else if errorMessage}
<div class="grid h-40 place-items-center">
<span class="text-4xl">{errorMessage}</span>
</div>
{:else}
<div class="relative h-40">
<Loader size={5} />
</div>
{/if}
{#if newConnectionModal !== null}
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
{/if}
</section>
</main>
<style>
.add-connection-button {
background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));
}
#connection-profile-grid {
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
}
</style>
@@ -1,53 +0,0 @@
<script lang="ts">
import Services from '$lib/services.json'
import Toggle from '$lib/components/util/toggle.svelte'
import { fly } from 'svelte/transition'
import type { SubmitFunction } from '@sveltejs/kit'
import { enhance } from '$app/forms'
export let connectionInfo: ConnectionInfo
export let submitFunction: SubmitFunction
$: serviceData = Services[connectionInfo.type]
const subHeaderItems: string[] = []
if ('username' in connectionInfo && connectionInfo.username) {
subHeaderItems.push(connectionInfo.username)
}
if ('serverName' in connectionInfo && connectionInfo.serverName) {
subHeaderItems.push(connectionInfo.serverName)
}
</script>
<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" />
{#if 'profilePicture' in connectionInfo}
<img src="/api/remoteImage?url={connectionInfo.profilePicture}" alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if}
</div>
<div>
<div>{serviceData.displayName}</div>
<div class="text-sm text-neutral-500">
{subHeaderItems.join(' - ')}
</div>
</div>
<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" />
<div class="p-4 text-sm text-neutral-400">
<div class="grid grid-cols-[3rem_auto] gap-4">
<Toggle on:toggled={(event) => console.log(event.detail.toggled)} />
<span>Place for config</span>
</div>
</div>
</section>
@@ -1,59 +0,0 @@
<script lang="ts">
import type { SubmitFunction } from '@sveltejs/kit'
import { scale } from 'svelte/transition'
import { createEventDispatcher } from 'svelte'
import { enhance } from '$app/forms'
export let submitFunction: SubmitFunction
const dispatch = createEventDispatcher()
</script>
<form method="post" use:enhance={submitFunction} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" transition:scale>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-925 px-8">
<h1 class="text-center text-4xl">Jellyfin Sign In</h1>
<div class="flex w-full flex-col gap-5">
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<div class="flex w-full flex-row gap-4">
<input type="text" name="username" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<input type="password" name="password" placeholder="Password" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
</div>
</div>
<div class="flex items-center justify-around text-lg">
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-95" on:click|preventDefault={() => dispatch('close')}>Cancel</button>
<button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-95" formaction="?/authenticateJellyfin">Submit</button>
</div>
</div>
</form>
<style>
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes rotation {
0% {
--gradient-angle: 0deg;
}
100% {
--gradient-angle: 360deg;
}
}
#main-box::before {
content: '';
position: absolute;
inset: -0.1rem;
z-index: -1;
background: conic-gradient(from var(--gradient-angle), var(--jellyfin-purple), var(--jellyfin-blue), var(--jellyfin-purple));
border-radius: inherit;
animation: rotation 15s linear infinite;
filter: blur(0.5rem);
}
#cancel-button:hover {
background-color: rgb(30 30 30);
}
#submit-button:hover {
background-color: color-mix(in srgb, var(--jellyfin-blue) 80%, black);
}
</style>
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('./$types').LayoutLoad} */
export const load = ({ url }) => {
return {
url: url.pathname,
}
}
@@ -0,0 +1,22 @@
<script>
import IconButton from '$lib/components/utility/iconButton.svelte'
</script>
<main class="h-full">
<h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center text-2xl">
<IconButton on:click={() => history.back()}>
<i slot="icon" class="fa-solid fa-arrow-left" />
</IconButton>
<span>Account</span>
</h1>
<section class="px-[5vw]">
<slot />
</section>
</main>
<style>
h1 {
height: 80px;
padding: 16px 5vw;
}
</style>
@@ -0,0 +1,50 @@
<script>
import IconButton from '$lib/components/utility/iconButton.svelte'
import { goto } from '$app/navigation'
export let data
const settingRoutes = {
connections: {
displayName: 'Connections',
uri: '/settings/connections',
icon: 'fa-solid fa-circle-nodes',
},
// devices: {
// displayName: 'Devices',
// uri: '/settings/devices',
// icon: 'fa-solid fa-mobile-screen',
// },
}
</script>
<nav class="h-full rounded-lg bg-neutral-950 p-6">
<h1 class="flex h-6 justify-between text-neutral-400">
<span>
<i class="fa-solid fa-gear" />
Settings
</span>
{#if data.url.split('/').at(-1) !== 'settings'}
<IconButton on:click={() => goto('/settings')}>
<i slot="icon" class="fa-solid fa-caret-left" />
</IconButton>
{/if}
</h1>
<ol class="ml-2 mt-4 flex flex-col gap-3 border-2 border-transparent border-l-neutral-500 px-2">
{#each Object.values(settingRoutes) as route}
<li>
{#if data.url === route.uri}
<div class="rounded-lg bg-neutral-500 px-3 py-1">
<i class={route.icon} />
{route.displayName}
</div>
{:else}
<a href={route.uri} class="block rounded-lg px-3 py-1 opacity-50 hover:bg-neutral-700">
<i class={route.icon} />
{route.displayName}
</a>
{/if}
</li>
{/each}
</ol>
</nav>
@@ -0,0 +1,111 @@
import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { UserConnections } from '$lib/server/db/users'
const createProfile = async (connectionData) => {
const { id, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry } = connectionData
switch (serviceType) {
case 'jellyfin':
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
const systemUrl = new URL('System/Info', serviceUrl).href
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData = await userResponse.json()
const systemData = await systemResponse.json()
return {
connectionId: id,
serviceType,
userId: serviceUserId,
username: userData?.Name,
serviceUrl: serviceUrl,
serverName: systemData?.ServerName,
}
default:
return null
}
}
/** @type {import('./$types').PageServerLoad} */
export const load = async ({ fetch, locals }) => {
const response = await fetch(`/api/user/connections?userId=${locals.userId}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
const allConnections = await response.json()
const connectionProfiles = []
if (allConnections) {
for (const connection of allConnections) {
const connectionProfile = await createProfile(connection)
connectionProfiles.push(connectionProfile)
}
}
return { connectionProfiles }
}
/** @type {import('./$types').Actions}} */
export const actions = {
authenticateJellyfin: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const queryParams = new URLSearchParams()
for (let field of formData) {
const [key, value] = field
queryParams.append(key, value)
}
const jellyfinAuthResponse = await fetch(`/api/jellyfin/auth?${queryParams.toString()}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
if (!jellyfinAuthResponse.ok) {
const jellyfinAuthError = await jellyfinAuthResponse.text()
return fail(jellyfinAuthResponse.status, { message: jellyfinAuthError })
}
const jellyfinAuthData = await jellyfinAuthResponse.json()
const accessToken = jellyfinAuthData.AccessToken
const jellyfinUserId = jellyfinAuthData.User.Id
const updateConnectionsResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
method: 'PATCH',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
body: JSON.stringify({ serviceType: 'jellyfin', serviceUserId: jellyfinUserId, serviceUrl: formData.get('serverUrl'), accessToken }),
})
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection = await updateConnectionsResponse.json()
const newConnectionData = UserConnections.getConnection(newConnection.id)
const jellyfinProfile = await createProfile(newConnectionData)
return { newConnection: jellyfinProfile }
},
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const connectionId = formData.get('connectionId')
const deleteConnectionResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
method: 'DELETE',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
body: JSON.stringify({ connectionId }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { deletedConnectionId: connectionId }
},
}
@@ -0,0 +1,134 @@
<script>
import { enhance } from '$app/forms'
import { fly } from 'svelte/transition'
import { JellyfinUtils } from '$lib/utils/utils'
import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/utils/stores.js'
import IconButton from '$lib/components/utility/iconButton.svelte'
import Toggle from '$lib/components/utility/toggle.svelte'
export let data
let connectionProfiles = data.connectionProfiles
const submitCredentials = ({ formData, action, cancel }) => {
switch (action.search) {
case '?/authenticateJellyfin':
const { serverUrl, username, password } = Object.fromEntries(formData)
if (!(serverUrl && username && password)) {
cancel()
return ($newestAlert = ['caution', 'All fields must be filled out'])
}
try {
new URL(serverUrl)
} catch {
cancel()
return ($newestAlert = ['caution', 'Server URL is invalid'])
}
const deviceId = JellyfinUtils.getLocalDeviceUUID()
formData.append('deviceId', deviceId)
break
case '?/deleteConnection':
break
default:
cancel()
}
return async ({ result }) => {
switch (result.type) {
case 'failure':
return ($newestAlert = ['warning', result.data.message])
case 'success':
modal = null
if (result.data?.newConnection) {
const newConnection = result.data.newConnection
connectionProfiles = [newConnection, ...connectionProfiles]
return ($newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`])
} else if (result.data?.deletedConnectionId) {
const id = result.data.deletedConnectionId
const indexToDelete = connectionProfiles.findIndex((profile) => profile.connectionId === id)
const serviceType = connectionProfiles[indexToDelete].serviceType
connectionProfiles.splice(indexToDelete, 1)
connectionProfiles = connectionProfiles
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
}
}
}
}
let modal
</script>
<main>
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
{#each Object.entries(Services) as [serviceType, serviceData]}
<button
class="bg-ne h-14 rounded-md"
style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));"
on:click={() => {
if (serviceType === 'jellyfin') modal = JellyfinAuthBox
}}
>
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button>
{/each}
</div>
</section>
<div class="grid gap-8">
{#each connectionProfiles as connectionProfile}
{@const serviceData = Services[connectionProfile.serviceType]}
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div>
<div>{connectionProfile?.username ? connectionProfile.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{#if connectionProfile.serviceType === 'jellyfin' && connectionProfile?.serverName}
- {connectionProfile.serverName}
{/if}
</div>
</div>
<div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionProfile.connectionId}`)}>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</div>
</header>
<hr class="mx-2 border-t-2 border-neutral-600" />
<div class="p-4 text-sm text-neutral-400">
<div class="grid grid-cols-[3rem_auto] gap-4">
<Toggle on:toggled={(event) => console.log(event.detail.toggled)} />
<span>Enable Connection</span>
</div>
</div>
</section>
{/each}
</div>
{#if modal}
<form method="post" use:enhance={submitCredentials} transition:fly={{ y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{#if typeof modal === 'string'}
{@const connectionId = modal.replace('delete-', '')}
{@const connection = connectionProfiles.find((profile) => profile.connectionId === connectionId)}
{@const serviceData = Services[connection.serviceType]}
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {serviceData.displayName} connection?</h1>
<div class="flex w-60 justify-around">
<input type="hidden" name="connectionId" value={connectionId} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault={() => (modal = null)}>Cancel</button>
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
</div>
</div>
{:else}
<svelte:component this={modal} on:close={() => (modal = null)} />
{/if}
</form>
{/if}
</main>
@@ -0,0 +1,52 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 px-8">
<h1 class="text-center text-4xl">Jellyfin Sign In</h1>
<div class="flex w-full flex-col gap-5">
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<div class="flex w-full flex-row gap-4">
<input type="text" name="username" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<input type="password" name="password" placeholder="Password" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
</div>
</div>
<div class="flex items-center justify-around text-lg">
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={() => dispatch('close')}>Cancel</button>
<button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button>
</div>
</div>
<style>
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes rotation {
0% {
--gradient-angle: 0deg;
}
100% {
--gradient-angle: 360deg;
}
}
#main-box::before {
content: '';
position: absolute;
inset: -0.1rem;
z-index: -1;
background: conic-gradient(from var(--gradient-angle), var(--jellyfin-purple), var(--jellyfin-blue), var(--jellyfin-purple));
border-radius: inherit;
animation: rotation 15s linear infinite;
filter: blur(0.5rem);
}
#cancel-button:hover {
background-color: rgb(30 30 30);
}
#submit-button:hover {
background-color: color-mix(in srgb, var(--jellyfin-blue) 80%, black);
}
</style>
@@ -0,0 +1,12 @@
{
"connectionId": "Database id of the respective connection",
"serviceType": "Type of service of the respective connection",
"userId": "The UUID of the account [req]",
"username": "The username of the account [req]",
"email": "Email asscociated w/ account [opt]",
"serviceUrl": "Id of source server [req]",
"serverName": "Name of source server [jellyfin]",
"V POTENTIAL": "TBD V",
"connectionEnabled": "[Toggle] boolean; enables/disables pulling data from the respective connection"
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('./$types').LayoutLoad} */
export const load = ({ url, locals }) => {
return {
url: url.pathname,
userId: locals.userId,
username: locals.username,
}
}
-9
View File
@@ -1,9 +0,0 @@
import type { LayoutServerLoad } from './$types'
export const ssr = false
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user,
}
}
+45 -30
View File
@@ -1,30 +1,45 @@
<script lang="ts">
import '../app.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
import AlertBox from '$lib/components/util/alertBox.svelte'
import { newestAlert, backgroundImage } from '$lib/stores'
import { fade } from 'svelte/transition'
let alertBox: AlertBox
$: if ($newestAlert && alertBox) alertBox.addAlert(...$newestAlert)
</script>
<div class="no-scrollbar relative h-screen overflow-x-clip font-notoSans text-white">
<div class="fixed isolate -z-10 h-full w-screen bg-black">
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
{#key $backgroundImage}
<img id="background-image" src={$backgroundImage} alt="" class="absolute h-1/2 w-full object-cover blur-lg" transition:fade={{ duration: 1000 }} />
{/key}
</div>
<slot />
<AlertBox bind:this={alertBox} />
</div>
<style>
#background-gradient {
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), black);
}
#background-image {
mask-image: linear-gradient(to bottom, black, rgba(0, 0, 0, 0.3));
}
</style>
<script>
import '../app.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
import AlertBox from '$lib/components/utility/alertBox.svelte'
import { newestAlert, backgroundImage, pageWidth } from '$lib/utils/stores.js'
import { fade } from 'svelte/transition'
import { onMount } from 'svelte'
let alertBox
$: addAlert($newestAlert)
const addAlert = (alertData) => {
if (alertBox) alertBox.addAlert(...alertData)
}
// Might want to change this functionallity to a fetch/preload/await for the image
const ytBg = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // <-- Default youtube music background
let loaded = false
onMount(() => (loaded = true))
</script>
<svelte:window bind:innerWidth={$pageWidth} />
<div class="no-scrollbar relative h-screen font-notoSans text-white">
<div class="fixed isolate -z-10 h-full w-screen bg-black">
<!-- This whole bg is a complete copy of ytmusic, design own at some point (Place for customization w/ album art etc?) (EDIT: Ok, it looks SICK with album art!) -->
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
{#if loaded}
{#key $backgroundImage}
<!-- May want to add a small blur filter in the event that the album/song image is below a certain resolution -->
<img id="background-image" src={$backgroundImage ? $backgroundImage : ytBg} alt="" class="absolute h-1/2 w-full object-cover blur-lg" transition:fade={{ duration: 1000 }} />
{/key}
{/if}
</div>
<slot />
<AlertBox bind:this={alertBox} />
</div>
<style>
#background-gradient {
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), black);
}
#background-image {
mask-image: linear-gradient(to bottom, black, rgba(0, 0, 0, 0.3));
}
</style>
-19
View File
@@ -1,19 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections = (await Promise.all(ids.map((id) => ConnectionFactory.getConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
const getConnectionInfo = (connection: Connection) =>
connection.getConnectionInfo().catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return null
})
const connectionInfo = (await Promise.all(connections.map(getConnectionInfo))).filter((connectionInfo): connectionInfo is ConnectionInfo => connectionInfo !== null)
return Response.json({ connections: connectionInfo })
}

Some files were not shown because too many files have changed in this diff Show More