Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 741cea2ef2 | |||
| 455a01982a | |||
| f10a184284 | |||
| 8453e51d3f | |||
| f17773838a | |||
| de20ee90b5 | |||
| 28c825b04b | |||
| ca80a6476f | |||
| 9dab826e53 | |||
| cb4cc1d949 | |||
| 292dc1425e | |||
| a98b258a03 | |||
| 11497f8b91 | |||
| fec4bba61e | |||
| 05f4b61ec7 | |||
| b443382f1a | |||
| 0b0c169fc5 | |||
| 8432184a87 | |||
| 28e4569507 | |||
| 2ee60ef302 | |||
| f11fa95aea | |||
| f9592f2d82 | |||
| 2848000d3c | |||
| 2ea07ba9fe | |||
| faf3794c8f | |||
| 54ab748c99 | |||
| 8cff75bc9e | |||
| 998dc81143 | |||
| 8e52bd71c4 | |||
| c5408d76b6 | |||
| 952c8383f9 | |||
| c01a7f6a03 | |||
| acb45803ac | |||
| 78b1f7e140 | |||
| dc18005a60 | |||
| a624f375e4 | |||
| a4bad9d73b | |||
| 2314bc638d | |||
| 48f60e2724 | |||
| a05796dbd6 | |||
| cd8360851f | |||
| 15db7f1aed | |||
| d50497e7d5 | |||
| b03565f06f | |||
| e8f09b159d | |||
| 79bbead5e4 | |||
| ade2ee9b86 | |||
| ae1b89cd6c | |||
| 5fc1883e7c | |||
| 1b4c91ba35 | |||
| fe37c8aa6e | |||
| c7b9b214b7 | |||
| c2236ab8ac | |||
| 75508159b1 | |||
| 25260ca3cd | |||
| b7d7c0c116 | |||
| 46e55f10c5 | |||
| 00fd41ae69 | |||
| 76ae00b78b | |||
| 9e3e6d6ec4 | |||
| 1608e03b97 | |||
| 85a17dcd89 | |||
| 416803af81 | |||
| 00003bd113 | |||
| c365bcc540 | |||
| ac8305bd72 | |||
| 8544f66397 | |||
| 269d79327e | |||
| a8241c6e19 | |||
| cb03d2661b | |||
| 09a23fe363 | |||
| f127087791 | |||
| c710f80178 | |||
| cbe9b60973 | |||
| 20454e22d1 | |||
| 909b78807f | |||
| 044b3616f9 | |||
| dda5b7f6d2 | |||
| b7daf9c27c | |||
| fcbcf6780d | |||
| 675e2b2d68 | |||
| b96c3848ad | |||
| 098ac487ec | |||
| 4ae54aa14c | |||
| 5bd5b603b0 | |||
| bee4c903ec | |||
| 1209b6ac25 | |||
| 4fcfdc0ee6 | |||
| 0ad1ace45b | |||
| e86b103af0 | |||
| 2b79dcdd0a | |||
| 701fc69bf0 | |||
| 830ab55023 | |||
| fd489b055c | |||
| d6d58a7c75 | |||
| 188b37b232 | |||
| 266a805ac0 | |||
| ae8f030afb |
+14
@@ -8,3 +8,17 @@ 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
|
||||
Vendored
+2
-1
@@ -1,2 +1,3 @@
|
||||
{
|
||||
}
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -1,38 +1,12 @@
|
||||
# create-svelte
|
||||
# Lazuli
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
||||
A self hosted client to stream music from all your favorite music streaming services all in one unified interface.
|
||||
|
||||
## Creating a project
|
||||
## Planned Features
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
- 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
|
||||
|
||||
```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.
|
||||

|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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
@@ -0,0 +1,276 @@
|
||||
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
|
||||
Generated
+4715
-3185
File diff suppressed because it is too large
Load Diff
+50
-29
@@ -1,31 +1,52 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
+5
-15
@@ -1,16 +1,12 @@
|
||||
@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;
|
||||
|
||||
/* 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 */
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Default scrollbar for Chrome, Safari, Edge and Opera */
|
||||
@@ -37,9 +33,3 @@
|
||||
--jellyfin-blue: #00a4dc;
|
||||
--youtube-red: #ff0000;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+211
@@ -0,0 +1,211 @@
|
||||
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 {}
|
||||
+3
-8
@@ -3,16 +3,11 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<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"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="no-scrollbar m-0">
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import ky from 'ky'
|
||||
|
||||
export const apiV1 = ky.create({
|
||||
prefixUrl: '/api/v1/',
|
||||
credentials: 'include',
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
@@ -1,43 +1,57 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!--
|
||||
@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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
@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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
@@ -1,26 +1,41 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,99 +1,88 @@
|
||||
<script>
|
||||
export let mediaData
|
||||
<script lang="ts">
|
||||
export let mediaItem: Song | Album | Artist | Playlist
|
||||
|
||||
import Services from '$lib/services.json'
|
||||
import IconButton from '$lib/components/utility/iconButton.svelte'
|
||||
import { backgroundImage, currentlyPlaying } from '$lib/utils/stores.js'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { queue } from '$lib/stores'
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
{: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}
|
||||
<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 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}
|
||||
<span class="mr-0.5 text-sm">,</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{#if mediaData.mediaType}
|
||||
<span>•</span>
|
||||
<i class="{iconClasses[mediaData.mediaType]} text-xs" style="color: var({Services[mediaData.serviceType].primaryColor});" />
|
||||
{/if}
|
||||
</div>
|
||||
{: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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#card-wrapper {
|
||||
perspective: 1000px;
|
||||
}
|
||||
#card-wrapper:focus-within #card-image {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
#card-wrapper:focus-within #card-glare {
|
||||
opacity: 1;
|
||||
}
|
||||
#card:hover {
|
||||
#thumbnail:hover {
|
||||
scale: 1.05;
|
||||
}
|
||||
#card:hover > #card-image {
|
||||
#thumbnail:hover #card-image {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
#card:hover > #card-glare {
|
||||
#thumbnail:hover #play-button {
|
||||
opacity: 1;
|
||||
}
|
||||
#card-image {
|
||||
mask-image: linear-gradient(to bottom, black 50%, transparent 95%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<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>
|
||||
@@ -1,182 +1,215 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<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>•</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>
|
||||
@@ -1,108 +0,0 @@
|
||||
<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}
|
||||
@@ -0,0 +1,62 @@
|
||||
<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>
|
||||
export let header = null
|
||||
export let cardDataList
|
||||
<script lang="ts">
|
||||
export let header: string
|
||||
export let cardDataList: (Song | Album | Artist | Playlist)[]
|
||||
|
||||
import Card from '$lib/components/media/mediaCard.svelte'
|
||||
import IconButton from '$lib/components/utility/iconButton.svelte'
|
||||
import MediaCard from '$lib/components/media/mediaCard.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
|
||||
let scrollable,
|
||||
scrollableWidth,
|
||||
let scrollable: HTMLDivElement,
|
||||
scrollableWidth: number,
|
||||
isScrollable = false,
|
||||
scrollpos = 0
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
|
||||
<section>
|
||||
<div class="flex h-10 items-center justify-between">
|
||||
{#if header}
|
||||
<h1 class="text-4xl"><strong>{header}</strong></h1>
|
||||
{/if}
|
||||
<h1 class="text-4xl"><strong>{header}</strong></h1>
|
||||
<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" />
|
||||
@@ -32,10 +30,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 py-4"
|
||||
class="no-scrollbar flex gap-6 overflow-y-hidden overflow-x-scroll scroll-smooth p-4"
|
||||
>
|
||||
{#each cardDataList as cardData}
|
||||
<Card mediaData={cardData} />
|
||||
{#each cardDataList as mediaItem}
|
||||
<MediaCard {mediaItem} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
<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}
|
||||
<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}
|
||||
+26
-30
@@ -1,30 +1,26 @@
|
||||
<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>
|
||||
<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>
|
||||
+49
-41
@@ -1,41 +1,49 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
@@ -0,0 +1,102 @@
|
||||
<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>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
@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>
|
||||
@@ -0,0 +1,59 @@
|
||||
<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>
|
||||
@@ -0,0 +1,68 @@
|
||||
<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>
|
||||
@@ -0,0 +1,74 @@
|
||||
<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,30 +1,29 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -1,73 +0,0 @@
|
||||
<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,70 +0,0 @@
|
||||
<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>
|
||||
@@ -1,52 +0,0 @@
|
||||
<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,38 +0,0 @@
|
||||
<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>
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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.
@@ -1,64 +0,0 @@
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
+2284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,898 @@
|
||||
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.
|
||||
+10
-14
@@ -1,14 +1,10 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
@@ -1,23 +0,0 @@
|
||||
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' },
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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)
|
||||
@@ -1,116 +0,0 @@
|
||||
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 = () => {}
|
||||
}
|
||||
+37
-143
@@ -1,155 +1,49 @@
|
||||
<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'
|
||||
<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'
|
||||
|
||||
export let data
|
||||
// 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
|
||||
|
||||
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',
|
||||
},
|
||||
}
|
||||
$: currentlyPlaying = $queue.current
|
||||
$: shuffled = $queue.isShuffled
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let playerWidth: number
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
<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()}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
#content-grid {
|
||||
#grid-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: max-content auto;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-rows: min-content auto;
|
||||
}
|
||||
#bottom-tab-list {
|
||||
padding: 16px 0px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
#content-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto min-content;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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() }
|
||||
}
|
||||
@@ -1,31 +1,72 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { newestAlert } from '$lib/utils/stores.js'
|
||||
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
||||
<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'
|
||||
|
||||
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)
|
||||
})
|
||||
export let data: PageData
|
||||
</script>
|
||||
|
||||
{#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 > 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}
|
||||
<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>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<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>
|
||||
@@ -1,8 +0,0 @@
|
||||
export async function load({ url }) {
|
||||
const { id, service } = Object.fromEntries(url.searchParams)
|
||||
|
||||
return {
|
||||
artistId: id,
|
||||
connectionId: service,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<script>
|
||||
export let data
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div>This is the page for artist: {data.artistId}</div>
|
||||
<div>From connection: {data.connectionId}</div>
|
||||
</section>
|
||||
@@ -1 +0,0 @@
|
||||
<div>Hello, this is where info about the music would go!</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
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()]) }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@@ -0,0 +1,22 @@
|
||||
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()]) }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
@@ -0,0 +1 @@
|
||||
<h1>Add subroutes for artist, automatically generated playlists, albums, and songs</h1>
|
||||
@@ -0,0 +1,41 @@
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user }
|
||||
}
|
||||
@@ -1 +1,18 @@
|
||||
<h1>This is where library items will go</h1>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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() }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@@ -0,0 +1,14 @@
|
||||
<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 +0,0 @@
|
||||
<main>Hello this is where playlist go</main>
|
||||
@@ -0,0 +1,13 @@
|
||||
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 +1,16 @@
|
||||
<h1>Search Page</h1>
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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 }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<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>
|
||||
@@ -0,0 +1,53 @@
|
||||
<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>
|
||||
@@ -0,0 +1,59 @@
|
||||
<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>
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('./$types').LayoutLoad} */
|
||||
export const load = ({ url }) => {
|
||||
return {
|
||||
url: url.pathname,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<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>
|
||||
@@ -1,50 +0,0 @@
|
||||
<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>
|
||||
@@ -1,111 +0,0 @@
|
||||
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 }
|
||||
},
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
<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>
|
||||
@@ -1,52 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('./$types').LayoutLoad} */
|
||||
export const load = ({ url, locals }) => {
|
||||
return {
|
||||
url: url.pathname,
|
||||
userId: locals.userId,
|
||||
username: locals.username,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const ssr = false
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
}
|
||||
}
|
||||
+30
-45
@@ -1,45 +1,30 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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
Reference in New Issue
Block a user