Plan and implement NovaTune Player SPA with auth, library, playback, playlists, and telemetry (plan) (project)
Build the NovaTune Player application - the listener-facing SPA with library browsing, audio playback, playlists, and upload functionality.
The player app (apps/player) provides:
Files:
src/stores/auth.ts - Pinia auth storesrc/features/auth/LoginPage.vuesrc/features/auth/RegisterPage.vuesrc/composables/useAuth.tsAPI Endpoints:
POST /auth/registerPOST /auth/loginPOST /auth/refreshPOST /auth/logoutImplementation:
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(null);
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
const user = ref<User | null>(null);
const deviceId = getOrCreateDeviceId();
const isAuthenticated = computed(() => !!accessToken.value);
async function login(email: string, password: string) {
const response = await authApi.login({ email, password, deviceId });
accessToken.value = response.accessToken;
refreshToken.value = response.refreshToken;
user.value = response.user;
localStorage.setItem('refresh_token', response.refreshToken);
}
async function refreshTokens() { /* ... */ }
async function logout() { /* ... */ }
return { accessToken, user, isAuthenticated, deviceId, login, refreshTokens, logout };
});
Files:
src/stores/library.ts - Library state storesrc/features/library/LibraryPage.vuesrc/features/library/TrackDetailPage.vuesrc/features/library/components/TrackCard.vuesrc/features/library/components/TrackList.vuesrc/features/library/components/SearchBar.vuesrc/features/library/composables/useTracks.tsAPI Endpoints:
GET /tracks - List tracks with filters, search, paginationGET /tracks/{trackId} - Get track detailsImplementation:
// composables/useTracks.ts
export function useTracks(filters: Ref<TrackFilters>) {
return useInfiniteQuery({
queryKey: ['tracks', filters],
queryFn: ({ pageParam }) => tracksApi.listTracks({
...filters.value,
cursor: pageParam,
}),
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 5 * 60 * 1000,
});
}
Files:
src/stores/player.ts - Player state storesrc/features/player/PlayerBar.vuesrc/features/player/components/PlayButton.vuesrc/features/player/components/ProgressBar.vuesrc/features/player/components/VolumeControl.vuesrc/features/player/components/QueuePanel.vueAPI Endpoints:
POST /tracks/{trackId}/stream - Get presigned streaming URLImplementation:
// stores/player.ts
export const usePlayerStore = defineStore('player', () => {
const audio = ref<HTMLAudioElement | null>(null);
const currentTrack = ref<Track | null>(null);
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const volume = ref(1);
const queue = ref<Track[]>([]);
async function play(track: Track) {
if (currentTrack.value?.id !== track.id) {
currentTrack.value = track;
const { streamUrl } = await streamApi.getStreamUrl(track.id);
if (!audio.value) {
audio.value = new Audio();
setupAudioListeners();
}
audio.value.src = streamUrl;
}
await audio.value?.play();
isPlaying.value = true;
await reportTelemetry('play_start');
}
// ... pause, seek, playNext, playPrevious, etc.
});
Files:
src/stores/playlists.ts - Playlist state storesrc/features/playlists/PlaylistsPage.vuesrc/features/playlists/PlaylistDetailPage.vuesrc/features/playlists/components/PlaylistCard.vuesrc/features/playlists/components/PlaylistTrackList.vuesrc/features/playlists/components/CreatePlaylistModal.vuesrc/features/playlists/composables/usePlaylists.tsAPI Endpoints:
GET /playlists - List user playlistsPOST /playlists - Create playlistGET /playlists/{playlistId} - Get playlist with tracksPATCH /playlists/{playlistId} - Update playlistDELETE /playlists/{playlistId} - Delete playlistPOST /playlists/{playlistId}/tracks - Add tracksDELETE /playlists/{playlistId}/tracks/{position} - Remove trackPOST /playlists/{playlistId}/reorder - Reorder tracksFiles:
src/features/upload/UploadPage.vuesrc/features/upload/components/UploadDropzone.vuesrc/features/upload/components/UploadProgress.vuesrc/features/upload/components/MetadataForm.vuesrc/features/upload/composables/useUpload.tsAPI Endpoints:
POST /uploads/initiate - Start upload sessionPOST /uploads/{uploadId}/complete - Complete uploadFiles:
packages/core/src/telemetry/index.tspackages/core/src/telemetry/playback.tsAPI Endpoints:
POST /telemetry/playback - Report playback eventsImplementation:
// packages/core/src/telemetry/playback.ts
export async function reportPlaybackEvent(event: PlaybackEvent): Promise<void> {
const hashedDeviceId = hashDeviceId(getOrCreateDeviceId());
await telemetryApi.ingestPlayback({
eventType: event.type,
trackId: event.trackId,
clientTimestamp: new Date().toISOString(),
positionSeconds: event.position,
sessionId: event.sessionId,
deviceId: hashedDeviceId,
clientVersion: import.meta.env.VITE_APP_VERSION,
});
}
// src/router/index.ts
const routes = [
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{ path: 'login', name: 'login', component: () => import('@/features/auth/LoginPage.vue') },
{ path: 'register', name: 'register', component: () => import('@/features/auth/RegisterPage.vue') },
],
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'library', component: () => import('@/features/library/LibraryPage.vue') },
{ path: 'track/:id', name: 'track', component: () => import('@/features/library/TrackDetailPage.vue') },
{ path: 'playlists', name: 'playlists', component: () => import('@/features/playlists/PlaylistsPage.vue') },
{ path: 'playlist/:id', name: 'playlist', component: () => import('@/features/playlists/PlaylistDetailPage.vue') },
{ path: 'upload', name: 'upload', component: () => import('@/features/upload/UploadPage.vue') },
],
},
];
<template>
<div class="flex h-screen flex-col">
<AppHeader />
<div class="flex flex-1 overflow-hidden">
<Sidebar />
<main class="flex-1 overflow-y-auto p-6">
<RouterView />
</main>
</div>
<PlayerBar />
</div>
</template>
// features/library/__tests__/TrackCard.test.ts
describe('TrackCard', () => {
it('renders track information', () => {
render(TrackCard, {
props: { track: mockTrack },
global: { plugins: [createTestingPinia()] },
});
expect(screen.getByText('Test Track')).toBeInTheDocument();
});
});
// e2e/auth.spec.ts
test('user can log in and see library', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('[data-testid="email"]', '[email protected]');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL('/');
});
doc/implementation/frontend/main.mddoc/implementation/stage-4-streaming.mddoc/implementation/stage-5-track-management.mddoc/implementation/stage-6/00-overview.mddoc/implementation/stage-7-telemetry.md