feat: add basic media player
This commit is contained in:
parent
653dff41dc
commit
6cb1c9d29c
19 changed files with 344 additions and 154 deletions
|
@ -15,7 +15,7 @@
|
|||
<Dashboard />
|
||||
</div>
|
||||
<!-- the infotainment system -->
|
||||
<div class="h-screen w-[65vw] right-0 fixed infotainment-container">
|
||||
<div class="min-h-screen w-[65vw] right-0 absolute infotainment-container">
|
||||
<!-- dynamic app system (edit appList.ts to add new apps) -->
|
||||
<div class="mx-10 mt-10">
|
||||
<svelte:component this={appList[activeApp].component} />
|
||||
|
|
1
client/src/globals.d.ts
vendored
1
client/src/globals.d.ts
vendored
|
@ -9,7 +9,6 @@ interface SongData {
|
|||
artist: string
|
||||
src: string
|
||||
coverImg: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface AppData {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
<!-- Genius codeium engineering automatically loads apps -->
|
||||
<div
|
||||
class="flex gap-4 justify-between h-20 bg-slate-300 backdrop-blur-sm rounded-xl px-6 shadow-md"
|
||||
class="app-bar backdrop-blur-xl bg-slate-300 bg-opacity-20 flex gap-4 justify-between h-20 rounded-xl px-6 shadow-md"
|
||||
>
|
||||
{#each Object.entries(appList) as [appName, appData]}
|
||||
<button
|
||||
|
@ -28,9 +28,9 @@
|
|||
|
||||
<style lang="postcss">
|
||||
.app-icon {
|
||||
@apply my-auto hover:brightness-50 h-16 rounded-2xl shadow-md transition-all duration-150;
|
||||
@apply my-auto hover:brightness-75 h-16 rounded-2xl shadow-md transition-all duration-150;
|
||||
}
|
||||
.selected {
|
||||
@apply brightness-50;
|
||||
@apply scale-110;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-4 w-full py-40 px-10 backdrop-blur-lg justify-center h-full camera-background rounded-3xl"
|
||||
class="flex gap-4 w-full py-40 px-10 backdrop-blur-lg justify-center h-full rounded-3xl shadow-md bg-slate-300 bg-opacity-30"
|
||||
>
|
||||
<div class="my-auto">
|
||||
<CameraContainer
|
||||
|
@ -18,17 +18,4 @@
|
|||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.camera-background {
|
||||
background: #4b79a1; /* fallback for old browsers */
|
||||
background: -webkit-linear-gradient(
|
||||
to right,
|
||||
#4b79a1,
|
||||
#283e51
|
||||
); /* Chrome 10-25, Safari 5.1-6 */
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#4b79a1,
|
||||
#283e51
|
||||
); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
|
||||
}
|
||||
</style>
|
||||
|
|
12
client/src/lib/Apps/MusicBrowser/MusicBrowser.svelte
Normal file
12
client/src/lib/Apps/MusicBrowser/MusicBrowser.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import Song from './Song.svelte'
|
||||
import { songList } from '../../Dashboard/MediaPlayer/songList'
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-4 w-full py-10 px-10 bg-blue-200 bg-opacity-25 backdrop-blur-xl h-full media-background rounded-3xl flex-wrap"
|
||||
>
|
||||
{#each Object.entries(songList) as [slug, song]}
|
||||
<Song {song} {slug} />
|
||||
{/each}
|
||||
</div>
|
50
client/src/lib/Apps/MusicBrowser/Song.svelte
Normal file
50
client/src/lib/Apps/MusicBrowser/Song.svelte
Normal file
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { musicStore } from '../../stores/musicStore'
|
||||
|
||||
export let song: SongData
|
||||
export let slug: string
|
||||
|
||||
let { title, artist, coverImg } = song
|
||||
|
||||
const handlePlay = () => {
|
||||
musicStore.setCurrent(slug)
|
||||
}
|
||||
|
||||
const handleQueueNext = () => {
|
||||
musicStore.queueNext(slug)
|
||||
}
|
||||
|
||||
const handleQueueLast = () => {
|
||||
musicStore.push(slug)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-1 flex-col rounded-lg p-4 bg-slate-800 backdrop-blur-xl shadow-md w-60 flex-grow basis-1/5"
|
||||
>
|
||||
<img src={coverImg} alt="album cover" class="shadow-md rounded-lg w-full" />
|
||||
<p class="mt-2 text-2xl font-medium">{title}</p>
|
||||
<p class="text-xl text-slate-400">{artist}</p>
|
||||
<div class="flex justify-center">
|
||||
<div class="my-auto flex gap-4">
|
||||
<button class="mt-2 hover:brightness-75" on:click={handlePlay}>
|
||||
<span class="material-symbols-outlined icon fill">play_arrow</span>
|
||||
</button>
|
||||
<button class="mt-2 hover:brightness-75" on:click={handleQueueNext}>
|
||||
<span class="material-symbols-outlined icon fill">next_plan</span>
|
||||
</button>
|
||||
<button class="mt-2 hover:brightness-75" on:click={handleQueueLast}>
|
||||
<span class="material-symbols-outlined icon">queue_music</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
.fill {
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
</style>
|
|
@ -1,22 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Song from './Song.svelte'
|
||||
import { songList } from './songList'
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-4 w-full py-10 px-10 backdrop-blur-xl h-full media-background rounded-3xl flex-wrap"
|
||||
>
|
||||
{#each songList as song}
|
||||
<Song {song} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.media-background {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(238, 174, 202, 1) 0%,
|
||||
rgba(148, 187, 233, 1) 100%
|
||||
);
|
||||
}
|
||||
</style>
|
|
@ -1,33 +0,0 @@
|
|||
<script lang="ts">
|
||||
export let song: SongData
|
||||
|
||||
let { title, artist, coverImg } = song
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-1 flex-col rounded-lg p-4 bg-slate-800 backdrop-blur-xl shadow-md w-60"
|
||||
>
|
||||
<img src={coverImg} alt="album cover" class="shadow-md rounded-lg w-full" />
|
||||
<p class="mt-2 text-3xl font-medium">{title}</p>
|
||||
<p class="text-xl text-slate-400">{artist}</p>
|
||||
<div class="flex justify-center">
|
||||
<div class="my-auto flex gap-4">
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon">skip_previous</span>
|
||||
</button>
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon">play_arrow</span>
|
||||
</button>
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon">skip_next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.icon {
|
||||
font-size: 28px;
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
</style>
|
|
@ -1,30 +0,0 @@
|
|||
export const songList = [
|
||||
{
|
||||
title: 'Danger Zone',
|
||||
artist: 'Kenny Loggins',
|
||||
src: '/static/songs/danger-zone/audio.mp3',
|
||||
coverImg: '/static/songs/danger-zone/cover.png',
|
||||
slug: 'danger-zone',
|
||||
},
|
||||
{
|
||||
title: 'Danger Zone',
|
||||
artist: 'Kenny Loggins',
|
||||
src: '/static/songs/danger-zone/audio.mp3',
|
||||
coverImg: '/static/songs/danger-zone/cover.png',
|
||||
slug: 'danger-zone',
|
||||
},
|
||||
{
|
||||
title: 'Danger Zone',
|
||||
artist: 'Kenny Loggins',
|
||||
src: '/static/songs/danger-zone/audio.mp3',
|
||||
coverImg: '/static/songs/danger-zone/cover.png',
|
||||
slug: 'danger-zone',
|
||||
},
|
||||
{
|
||||
title: 'Danger Zone',
|
||||
artist: 'Kenny Loggins',
|
||||
src: '/static/songs/danger-zone/audio.mp3',
|
||||
coverImg: '/static/songs/danger-zone/cover.png',
|
||||
slug: 'danger-zone',
|
||||
},
|
||||
]
|
|
@ -1,5 +1,5 @@
|
|||
import Camera from './Camera/Camera.svelte'
|
||||
import MusicBrowser from './MusicPlayer/MusicBrowser.svelte'
|
||||
import MusicBrowser from './MusicBrowser/MusicBrowser.svelte'
|
||||
|
||||
export const appList = {
|
||||
'camera': {
|
||||
|
|
|
@ -2,9 +2,12 @@
|
|||
import TopBar from './TopBar/TopBar.svelte'
|
||||
import Speedometer from './Speedometer.svelte'
|
||||
import SpeedLimit from './SpeedLimit.svelte'
|
||||
import MediaDisplay from './MediaDisplay/MediaDisplay.svelte'
|
||||
import MediaDisplay from './MediaPlayer/MediaDisplay.svelte'
|
||||
import Player from './MediaPlayer/Player.svelte'
|
||||
</script>
|
||||
|
||||
<Player />
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="px-5">
|
||||
<TopBar />
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
export let playing = false
|
||||
</script>
|
||||
|
||||
<div class="my-auto flex gap-4">
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon">skip_previous</span>
|
||||
</button>
|
||||
{#if playing}
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon"> pause </span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon">play_arrow</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="mt-2">
|
||||
<span class="material-symbols-outlined icon">skip_next</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.icon {
|
||||
font-size: 34px;
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
</style>
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Controls from './Controls.svelte'
|
||||
</script>
|
||||
|
||||
<div class="rounded-t-lg bg-neutral-800 px-4 py-2 h-24 flex justify-between">
|
||||
<div class="flex gap-6">
|
||||
<div class="aspect-square">
|
||||
<img
|
||||
src="https://upload.wikimedia.org/wikipedia/en/thumb/2/2c/Loggins_-_Danger_Zone_single_cover.png/220px-Loggins_-_Danger_Zone_single_cover.png"
|
||||
alt="album cover"
|
||||
class="w-full h-full object-cover rounded-lg shadow-sm shadow-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<p class="text-xl font-medium">Danger Zone</p>
|
||||
<p class="text-lg text-slate-400">Kenny Loggins</p>
|
||||
</div>
|
||||
</div>
|
||||
<Controls playing={false} />
|
||||
</div>
|
33
client/src/lib/Dashboard/MediaPlayer/Controls.svelte
Normal file
33
client/src/lib/Dashboard/MediaPlayer/Controls.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let playing = false
|
||||
</script>
|
||||
|
||||
<div class="my-auto flex gap-4 mr-4">
|
||||
<button
|
||||
class="mt-2 hover:brightness-75"
|
||||
on:click={() => dispatch('previous')}
|
||||
>
|
||||
<span class="material-symbols-outlined icon">skip_previous</span>
|
||||
</button>
|
||||
<button class="mt-2 hover:brightness-75" on:click={() => dispatch('toggle')}>
|
||||
{#if playing}
|
||||
<span class="material-symbols-outlined icon">pause</span>
|
||||
{:else}
|
||||
<span class="material-symbols-outlined icon">play_arrow</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="mt-2 hover:brightness-75" on:click={() => dispatch('skip')}>
|
||||
<span class="material-symbols-outlined icon">skip_next</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.icon {
|
||||
font-size: 34px;
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
</style>
|
37
client/src/lib/Dashboard/MediaPlayer/MediaDisplay.svelte
Normal file
37
client/src/lib/Dashboard/MediaPlayer/MediaDisplay.svelte
Normal file
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import Controls from './Controls.svelte'
|
||||
import { musicStore } from '../../stores/musicStore'
|
||||
import { songList } from './songList'
|
||||
|
||||
$: currentSong = $musicStore.queue[$musicStore.currentIndex]
|
||||
$: songData = songList[currentSong]
|
||||
|
||||
const skip = () => {
|
||||
musicStore.skip()
|
||||
}
|
||||
|
||||
const handleRewind = () => {}
|
||||
|
||||
const toggle = () => {
|
||||
musicStore.toggle()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if songData}
|
||||
<div class="rounded-t-lg bg-neutral-800 px-4 py-2 h-24 flex justify-between">
|
||||
<div class="flex gap-6">
|
||||
<div class="aspect-square">
|
||||
<img
|
||||
src={songData.coverImg}
|
||||
alt="album cover"
|
||||
class="w-full h-full object-cover rounded-lg shadow-sm shadow-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<p class="text-xl font-medium">{songData.title}</p>
|
||||
<p class="text-lg text-slate-400">{songData.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Controls on:skip={skip} on:toggle={toggle} playing={$musicStore.playing} />
|
||||
</div>
|
||||
{/if}
|
26
client/src/lib/Dashboard/MediaPlayer/Player.svelte
Normal file
26
client/src/lib/Dashboard/MediaPlayer/Player.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { AudioManager } from './audioManager'
|
||||
import { musicStore } from '../../stores/musicStore'
|
||||
import { songList } from './songList'
|
||||
|
||||
const audioManager = new AudioManager()
|
||||
$: currentSong = songList[$musicStore.queue[$musicStore.currentIndex]]
|
||||
|
||||
let src: string = ''
|
||||
$: {
|
||||
if (currentSong) src = currentSong.src
|
||||
console.log(currentSong)
|
||||
}
|
||||
|
||||
$: {
|
||||
if (src !== '' && $musicStore.playing) {
|
||||
audioManager.playAudio(src)
|
||||
console.log(src)
|
||||
} else if (!$musicStore.playing) {
|
||||
console.log('stopping')
|
||||
audioManager.stopAudio()
|
||||
}
|
||||
console.log($musicStore.queue)
|
||||
console.log($musicStore.currentIndex)
|
||||
}
|
||||
</script>
|
102
client/src/lib/Dashboard/MediaPlayer/audioManager.ts
Normal file
102
client/src/lib/Dashboard/MediaPlayer/audioManager.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
export class AudioManager {
|
||||
private audioContext: AudioContext | null = null
|
||||
private currentSource: AudioBufferSourceNode | null = null
|
||||
private currentBuffer: AudioBuffer | null = null // Stores the current audio buffer
|
||||
private startTime: number = 0 // When the current playback started
|
||||
private pauseTime: number = 0 // Track where we paused
|
||||
private currentToken: number = 0 // Unique token for each play request
|
||||
private isPaused: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.initAudioContext()
|
||||
}
|
||||
|
||||
private async initAudioContext() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new AudioContext()
|
||||
}
|
||||
}
|
||||
|
||||
public async playAudio(url: string): Promise<void> {
|
||||
if (this.isPaused && this.currentBuffer) {
|
||||
// If paused, resume instead of reloading
|
||||
this.resume()
|
||||
return
|
||||
}
|
||||
|
||||
this.pauseTime = 0 // Reset pause time for a new track
|
||||
const playToken = ++this.currentToken // Update the token for this request
|
||||
await this.initAudioContext()
|
||||
|
||||
if (this.audioContext) {
|
||||
this.stopAudio() // Stop any currently playing audio
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
if (this.currentToken !== playToken) {
|
||||
return // Abort if a newer request has been made
|
||||
}
|
||||
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
|
||||
this.currentBuffer = audioBuffer // Save the buffer for potential pausing/resuming
|
||||
|
||||
this.startPlayback(audioBuffer, 0) // Start playback from the beginning
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startPlayback(buffer: AudioBuffer, offset: number) {
|
||||
const source = this.audioContext!.createBufferSource()
|
||||
source.buffer = buffer
|
||||
source.connect(this.audioContext!.destination)
|
||||
source.start(0, offset)
|
||||
this.startTime = this.audioContext!.currentTime - offset
|
||||
this.currentSource = source
|
||||
this.isPaused = false
|
||||
|
||||
source.onended = () => {
|
||||
if (!this.isPaused) {
|
||||
this.currentBuffer = null // Clear the buffer if playback finishes normally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public pause() {
|
||||
if (!this.isPaused && this.currentSource && this.audioContext) {
|
||||
this.pauseTime = this.audioContext.currentTime - this.startTime
|
||||
this.currentSource.stop()
|
||||
this.isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
public resume() {
|
||||
if (this.isPaused && this.currentBuffer) {
|
||||
this.startPlayback(this.currentBuffer, this.pauseTime)
|
||||
}
|
||||
}
|
||||
|
||||
public stopAudio() {
|
||||
if (this.currentSource) {
|
||||
this.currentSource.stop()
|
||||
this.currentSource = null
|
||||
this.currentBuffer = null // Clear the current buffer
|
||||
this.isPaused = false
|
||||
this.pauseTime = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example:
|
||||
const audioManager = new AudioManager()
|
||||
const audioUrl = 'https://example.com/path/to/your/audio/file.mp3'
|
||||
|
||||
// To play the audio
|
||||
audioManager.playAudio(audioUrl)
|
||||
|
||||
// To pause the audio
|
||||
audioManager.pause()
|
||||
|
||||
// To resume the audio
|
||||
audioManager.resume()
|
14
client/src/lib/Dashboard/MediaPlayer/songList.ts
Normal file
14
client/src/lib/Dashboard/MediaPlayer/songList.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export const songList: { [key: string]: SongData } = {
|
||||
'danger-zone': {
|
||||
title: 'Danger Zone',
|
||||
artist: 'Kenny Loggins',
|
||||
src: '/static/songs/danger-zone/audio.mp3',
|
||||
coverImg: '/static/songs/danger-zone/cover.png',
|
||||
},
|
||||
'monko-zone': {
|
||||
title: 'Danger Zone',
|
||||
artist: 'Kenny Loggins',
|
||||
src: '/static/songs/danger-zone/audio.mp3',
|
||||
coverImg: '/static/songs/danger-zone/cover.png',
|
||||
},
|
||||
}
|
60
client/src/lib/stores/musicStore.ts
Normal file
60
client/src/lib/stores/musicStore.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { writable } from 'svelte/store'
|
||||
|
||||
interface MusicQueue {
|
||||
queue: string[]
|
||||
currentIndex: number
|
||||
playing: boolean
|
||||
}
|
||||
|
||||
function createMusicStore() {
|
||||
const { subscribe, set, update } = writable<MusicQueue>({
|
||||
queue: [],
|
||||
currentIndex: 0,
|
||||
playing: false,
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
push: (songSlug: string) =>
|
||||
update(store => {
|
||||
store.queue.push(songSlug)
|
||||
return store
|
||||
}),
|
||||
skip: () =>
|
||||
update(store => {
|
||||
let next = store.queue[store.currentIndex + 1]
|
||||
|
||||
if (next !== undefined) {
|
||||
store.currentIndex++
|
||||
}
|
||||
return store
|
||||
}),
|
||||
setCurrent: (songSlug: string) =>
|
||||
update(store => {
|
||||
store.currentIndex = store.queue.length
|
||||
store.queue.push(songSlug)
|
||||
return store
|
||||
}),
|
||||
queueNext: (songSlug: string) =>
|
||||
update(store => {
|
||||
store.queue.splice(store.currentIndex + 1, 0, songSlug)
|
||||
return store
|
||||
}),
|
||||
pause: update(store => {
|
||||
store.playing = false
|
||||
return store
|
||||
}),
|
||||
play: update(store => {
|
||||
store.playing = true
|
||||
return store
|
||||
}),
|
||||
toggle: () =>
|
||||
update(store => {
|
||||
store.playing = !store.playing
|
||||
return store
|
||||
}),
|
||||
reset: () => set({ queue: [], currentIndex: 0, playing: false }),
|
||||
}
|
||||
}
|
||||
|
||||
export const musicStore = createMusicStore()
|
Loading…
Reference in a new issue