I can't believe I figured out how to emulate ytmusic api calls

This commit is contained in:
Eclypsed
2024-02-24 02:06:02 -05:00
parent c7b9b214b7
commit fe37c8aa6e
12 changed files with 166 additions and 72 deletions

62
package-lock.json generated
View File

@@ -1145,14 +1145,15 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
}, },
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
"integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"function-bind": "^1.1.2", "function-bind": "^1.1.2",
"get-intrinsic": "^1.2.3", "get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.0" "set-function-length": "^1.2.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -1373,17 +1374,19 @@
} }
}, },
"node_modules/define-data-property": { "node_modules/define-data-property": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"get-intrinsic": "^1.2.2", "gopd": "^1.0.1"
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dequal": { "node_modules/dequal": {
@@ -1464,6 +1467,17 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"dependencies": {
"get-intrinsic": "^1.2.4"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -1658,9 +1672,9 @@
} }
}, },
"node_modules/gaxios": { "node_modules/gaxios": {
"version": "6.2.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.2.0.tgz", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.3.0.tgz",
"integrity": "sha512-H6+bHeoEAU5D6XNc6mPKeN5dLZqEDs9Gpk6I+SZBEzK5So58JVrHPmevNi35fRl1J9Y5TaeLW0kYx3pCJ1U2mQ==", "integrity": "sha512-p+ggrQw3fBwH2F5N/PAI4k/G/y1art5OxKpb2J2chwNNHM4hHuAOtivjPuirMF4KNKwTTUal/lPfL2+7h2mEcg==",
"dependencies": { "dependencies": {
"extend": "^3.0.2", "extend": "^3.0.2",
"https-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.1",
@@ -1862,20 +1876,20 @@
} }
}, },
"node_modules/has-property-descriptors": { "node_modules/has-property-descriptors": {
"version": "1.0.1", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dependencies": { "dependencies": {
"get-intrinsic": "^1.2.2" "es-define-property": "^1.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-proto": { "node_modules/has-proto": {
"version": "1.0.1", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
"integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -1906,9 +1920,9 @@
} }
}, },
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.2", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
"integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
"dependencies": { "dependencies": {
"agent-base": "^7.0.2", "agent-base": "^7.0.2",
"debug": "4" "debug": "4"

Binary file not shown.

View File

@@ -13,7 +13,7 @@ const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections(
userId VARCHAR(36) NOT NULL, userId VARCHAR(36) NOT NULL,
type VARCHAR(36) NOT NULL, type VARCHAR(36) NOT NULL,
service TEXT, service TEXT,
tokens TEXT tokens TEXT,
FOREIGN KEY(userId) REFERENCES Users(id) FOREIGN KEY(userId) REFERENCES Users(id)
)` )`
db.exec(initUsersTable), db.exec(initConnectionsTable) db.exec(initUsersTable), db.exec(initConnectionsTable)
@@ -77,11 +77,11 @@ export class Connections {
return connections return connections
} }
static addConnection<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, 'id' | 'type'>): DBConnectionData<T> { static addConnection<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, 'id' | 'type'>): string {
const connectionId = generateUUID() const connectionId = generateUUID()
const { userId, service, tokens } = connectionData const { userId, service, tokens } = connectionData
db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, service, tokens) db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
return this.getConnection(connectionId) as DBConnectionData<T> return connectionId
} }
static deleteConnection = (id: string): void => { static deleteConnection = (id: string): void => {
@@ -91,7 +91,7 @@ export class Connections {
static updateTokens = (id: string, accessToken?: string, refreshToken?: string, expiry?: number): void => { static updateTokens = (id: string, accessToken?: string, refreshToken?: string, expiry?: number): void => {
const newTokens = { accessToken, refreshToken, expiry } const newTokens = { accessToken, refreshToken, expiry }
const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(newTokens, id) const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(newTokens), id)
if (commandInfo.changes === 0) throw new Error('Failed to update tokens') if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
} }

14
src/lib/services.json Normal file
View File

@@ -0,0 +1,14 @@
{
"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"
}
}

View File

@@ -1,20 +1,5 @@
import { google } from 'googleapis' import { google } from 'googleapis'
export const serviceData = {
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',
},
}
export class Jellyfin { export class Jellyfin {
static audioPresets = (userId: string) => { static audioPresets = (userId: string) => {
return { return {
@@ -140,6 +125,16 @@ export class Jellyfin {
} }
export class YouTubeMusic { export class YouTubeMusic {
static baseHeaders = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
Cookie: 'SOCS=CAI;',
}
static fetchServiceInfo = async (userId: string, accessToken: string): Promise<Connection<'youtube-music'>['service']> => { static fetchServiceInfo = async (userId: string, accessToken: string): Promise<Connection<'youtube-music'>['service']> => {
const youtube = google.youtube('v3') const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken })
@@ -151,4 +146,58 @@ export class YouTubeMusic {
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
} }
} }
static getVisitorId = async (accessToken: string): Promise<string> => {
const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
const visitorIdResponse = await fetch('https://music.youtube.com', { headers })
const visitorIdText = await visitorIdResponse.text()
const regex = /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g
const matches = []
let match
while ((match = regex.exec(visitorIdText)) !== null) {
const capturedGroup = match[1]
matches.push(capturedGroup)
}
let visitorId = ''
if (matches.length > 0) {
const ytcfg = JSON.parse(matches[0])
visitorId = ytcfg.VISITOR_DATA
}
return visitorId
}
static getHome = async (accessToken: string) => {
const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
function formatDate(): string {
const currentDate = new Date()
const year = currentDate.getUTCFullYear()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
return year + month + day
}
const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, {
headers,
method: 'POST',
body: JSON.stringify({
browseId: 'FEmusic_home',
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.' + formatDate() + '.01.00',
hl: 'en',
},
},
}),
})
const data = await response.json()
console.log(response.status)
console.log(data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicCarouselShelfRenderer.contents[0].musicTwoRowItemRenderer.title)
}
} }

View File

@@ -6,5 +6,5 @@
</script> </script>
<div id="main"> <div id="main">
<ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} /> <!-- <ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} /> -->
</div> </div>

View File

@@ -17,6 +17,7 @@ export const GET: RequestHandler = async ({ url }) => {
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken) connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
break break
} }
connections.push(connection)
} }
return Response.json({ connections }) return Response.json({ connections })

View File

@@ -1,6 +1,7 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private' import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { Jellyfin } from '$lib/services' import { Jellyfin, YouTubeMusic } from '$lib/services'
import { google } from 'googleapis'
// This is temporary functionally for the sake of developing the app. // This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations // In the future will implement more robust algorithm for offering recommendations
@@ -33,6 +34,9 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection)) for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection))
break break
case 'youtube-music':
YouTubeMusic.getHome(tokens.accessToken)
break
} }
} }

View File

@@ -43,15 +43,22 @@ export const actions: Actions = {
return fail(400, { message: 'Could not reach Jellyfin server' }) return fail(400, { message: 'Could not reach Jellyfin server' })
} }
const newConnection = Connections.addConnection('jellyfin', { const newConnectionId = Connections.addConnection('jellyfin', {
userId: locals.user.id, userId: locals.user.id,
service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() },
tokens: { accessToken: authData.AccessToken }, tokens: { accessToken: authData.AccessToken },
}) })
return { newConnection } const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY },
})
const responseData = await response.json()
return { newConnection: responseData.connections[0] }
}, },
youtubeMusicLogin: async ({ request, locals }) => { youtubeMusicLogin: async ({ request, fetch, locals }) => {
const formData = await request.formData() const formData = await request.formData()
const { code } = Object.fromEntries(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 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.
@@ -61,13 +68,20 @@ export const actions: Actions = {
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
const newConnection = Connections.addConnection('youtube-music', { const newConnectionId = Connections.addConnection('youtube-music', {
userId: locals.user.id, userId: locals.user.id,
service: { userId: userChannel.id! }, service: { userId: userChannel.id! },
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
}) })
return { newConnection } const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY },
})
const responseData = await response.json()
return { newConnection: responseData.connections[0] }
}, },
deleteConnection: async ({ request }) => { deleteConnection: async ({ request }) => {
const formData = await request.formData() const formData = await request.formData()

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { serviceData } from '$lib/services' import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte' import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js' import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js' import type { PageServerData } from './$types.js'
@@ -38,7 +38,7 @@
connections = [...connections, newConnection] connections = [...connections, newConnection]
newConnectionModal = null newConnectionModal = null
return ($newestAlert = ['success', `Added ${serviceData[newConnection.type].displayName}`]) return ($newestAlert = ['success', `Added ${Services[newConnection.type].displayName}`])
} }
} }
} }
@@ -75,9 +75,6 @@
} }
const profileActions: SubmitFunction = ({ action, cancel }) => { const profileActions: SubmitFunction = ({ action, cancel }) => {
console.log(action)
cancel()
return ({ result }) => { return ({ result }) => {
if (result.type === 'failure') { if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message]) return ($newestAlert = ['warning', result.data?.message])
@@ -89,7 +86,7 @@
connections.splice(indexToDelete, 1) connections.splice(indexToDelete, 1)
connections = connections connections = connections
return ($newestAlert = ['success', `Deleted ${serviceData[serviceType].displayName}`]) return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
} }
} }
} }
@@ -102,11 +99,11 @@
<h1 class="py-2 text-xl">Add Connection</h1> <h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4"> <div class="flex flex-wrap gap-2 pb-4">
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}> <button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<img src={serviceData.jellyfin.icon} alt="{serviceData.jellyfin.displayName} icon" class="aspect-square h-full p-2" /> <img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}> <form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
<button class="add-connection-button h-14 rounded-md"> <button class="add-connection-button h-14 rounded-md">
<img src={serviceData['youtube-music'].icon} alt="{serviceData['youtube-music'].displayName} icon" class="aspect-square h-full p-2" /> <img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
</form> </form>
</div> </div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { serviceData } from '$lib/services' import Services from '$lib/services.json'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte' import Toggle from '$lib/components/util/toggle.svelte'
import type { SubmitFunction } from '@sveltejs/kit' import type { SubmitFunction } from '@sveltejs/kit'
@@ -9,26 +9,27 @@
export let connection: Connection<serviceType> export let connection: Connection<serviceType>
export let submitFunction: SubmitFunction export let submitFunction: SubmitFunction
$: reactiveServiceData = serviceData[connection.type] $: serviceData = Services[connection.type]
let showModal = false let showModal = false
const subHeaderItems: string[] = []
if ('username' in connection.service && connection.service.username) subHeaderItems.push(connection.service.username)
if ('serverName' in connection.service && connection.service.serverName) subHeaderItems.push(connection.service.serverName)
</script> </script>
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}> <section class="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"> <header class="flex h-20 items-center gap-4 p-4">
<div class="relative aspect-square h-full p-1"> <div class="relative aspect-square h-full p-1">
<img src={reactiveServiceData.icon} alt="{reactiveServiceData.displayName} icon" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" />
{#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'} {#if 'profilePicture' in connection.service && connection.service.profilePicture}
<img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" /> <img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if} {/if}
</div> </div>
<div> <div>
<div>Username</div> <div>{serviceData.displayName}</div>
<div class="text-sm text-neutral-500"> <div class="text-sm text-neutral-500">
{reactiveServiceData.displayName} {subHeaderItems.join(' - ')}
{#if 'serverName' in connection.service}
- {connection.service.serverName}
{/if}
</div> </div>
</div> </div>
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2"> <div class="relative ml-auto flex h-8 flex-row-reverse gap-2">

View File

@@ -1,6 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
}); })