Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- [hitstar] Show current game mode (Classic / Range) as small grey label top-left, only visible during an active game (hidden in the main menu).

### Changed

- [hitstar] Remove Spotify embed (full iframe) from the reveal phase; only round tracker, abort button, flipped track card and next-round button are shown after guessing.
- [hitstar] Spotify track search now uses a single random year (1955–2025) per request instead of a fixed range to improve year distribution across decades.
- [hitstar] Add `genre:pop` filter to Spotify search query to increase the share of well-known tracks.

- [backend] Auto-seed toggles on application startup: missing toggles are created with default values from config; existing toggles are never overwritten.
- [hitstar] Add animated step-card tutorial in the menu that loops through 5 game steps with icons and auto-highlights each card every 2 seconds.
- [hitstar] Multi-mode architecture with Carbon Tabs (horizontal, compact, centered, no dropdown on any viewport) with spacing below for mode selection on the Hitstar start page.
- [hitstar] New "Range" game mode: set a 1–5 year range instead of guessing exactly; narrower ranges earn more points (max 50).
- [hitstar] Replace Spotify iframe embed in guessing phase with Spotify iFrame API: hidden iframe controlled by custom Play/Pause and Replay buttons for a clean, maskless player UI.

### Fixed

- [hitstar] Clicking a game mode tab no longer auto-starts a round; the menu always stays visible after page load or tab switch, and only "New Game" triggers the game.
- [hitstar] Album cover on the track card is now smaller on mobile viewports so all metadata (title, artist, album, year) fits within the card bounds without being clipped.
- [hitstar] Spotify iFrame API player no longer throws PlaybackError after the 30-second preview ends; clicking Play or Replay reloads the track via `loadUri` and starts playback automatically.
- [hitstar] Range mode NumberInput fields now correctly display the default value (2000) by overriding Carbon's oversized `padding-right` that was truncating the 4-digit year.
- [hitstar] CSS selector for Range submit button narrowed from `:global(button)` to `:global(button.bx--btn)` to prevent accidentally resizing the NumberInput stepper buttons.

### Changed

- [hitstar] Refactored Hitstar into shell + separate mode components (HitstarClassic, HitstarRange) with shared sub-components (HitstarRoundTracker, HitstarGameTopBar, HitstarLoadingScreen, HitstarResultsScreen).
- [hitstar] Refactored localStorage keys to mode-specific pattern (hitstar.classic.*, hitstar.range.*) with automatic migration of existing data.
- [hitstar] Hide best-score tile in menu when no score has been recorded yet.
- [hitstar] Replace static ProgressIndicator with custom icon step-cards (Headphones, Calendar, CheckmarkFilled, Repeat, Trophy) for the game tutorial.
- [hitstar] New "Hitstar" music year guessing game: listen to a 30-second preview and guess the release year across 10 rounds.
- [hitstar] Backend NX library `spotify` with iTunes Search API integration and random track endpoint (`GET /spotify/random-track`).
- [hitstar] Renamed backend NX library `spotify` to `hitstar`; endpoint changed from `GET /spotify/random-track` to `GET /hitstar/random-track`.
- [hitstar] Frontend route `/hitstar` with state machine (MENU → LOADING → GUESSING → REVEAL → RESULTS).
- [hitstar] Audio preview plays automatically in GUESSING state via iTunes preview URL.
- [hitstar] CSS 3D flip card animation on answer reveal, confetti for correct guesses, shake animation for wrong answers.
Expand Down
4 changes: 2 additions & 2 deletions backend/apps/tilloh-dev/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AdminModule } from '@backend/admin';
import { JokesModule } from '@backend/jokes';
import { SpotifyModule } from '@backend/spotify';
import { HitstarModule } from '@backend/hitstar';
import { MemorandumModule } from '@backend/memorandum';
import { OcrModule } from '@backend/ocr';
import { SharedControllerHealthModule } from '@backend/shared-controller-health';
Expand Down Expand Up @@ -72,7 +72,7 @@ import { EnvironmentVariables, validate } from './env.validation';
MemorandumModule,
JokesModule,
OcrModule,
SpotifyModule,
HitstarModule,
TodoControllerModule,
],
providers: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable */
export default {
displayName: 'spotify',
displayName: 'hitstar',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/libs/spotify',
coverageDirectory: '../../coverage/libs/hitstar',
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "spotify",
"name": "hitstar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/spotify/src",
"sourceRoot": "libs/hitstar/src",
"projectType": "library",
"tags": [],
"targets": {
Expand All @@ -17,7 +17,7 @@
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "libs/spotify/jest.config.ts"
"jestConfig": "libs/hitstar/jest.config.ts"
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions backend/libs/hitstar/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lib/hitstar.controller';
export * from './lib/hitstar.module';
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpotifyController } from './spotify.controller';
import { SpotifyService } from './spotify.service';
import { HitstarController } from './hitstar.controller';
import { HitstarService } from './hitstar.service';

const mockTrack = {
id: 'track-id-1',
Expand All @@ -13,25 +13,25 @@ const mockTrack = {
previewUrl: 'https://p.scdn.co/mp3-preview/abc123',
};

describe('SpotifyController', () => {
let controller: SpotifyController;
let service: SpotifyService;
describe('HitstarController', () => {
let controller: HitstarController;
let service: HitstarService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SpotifyController],
controllers: [HitstarController],
providers: [
{
provide: SpotifyService,
provide: HitstarService,
useValue: {
getRandomTrack: jest.fn().mockResolvedValue(mockTrack),
},
},
],
}).compile();

controller = module.get<SpotifyController>(SpotifyController);
service = module.get<SpotifyService>(SpotifyService);
controller = module.get<HitstarController>(HitstarController);
service = module.get<HitstarService>(HitstarService);
});

it('should be defined', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import {
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { SpotifyService } from './spotify.service';
import { HitstarService } from './hitstar.service';

@ApiTags('spotify')
@Controller('/spotify')
export class SpotifyController {
constructor(private spotifyService: SpotifyService) {}
@ApiTags('hitstar')
@Controller('/hitstar')
export class HitstarController {
constructor(private hitstarService: HitstarService) {}

@Public()
@ApiOkResponse({
Expand All @@ -21,6 +21,6 @@ export class SpotifyController {
@ApiBadRequestResponse({ description: 'Bad or malformed request.' })
@Get('/random-track')
getRandomTrack() {
return this.spotifyService.getRandomTrack();
return this.hitstarService.getRandomTrack();
}
}
13 changes: 13 additions & 0 deletions backend/libs/hitstar/src/lib/hitstar.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HitstarController } from './hitstar.controller';
import { HitstarService } from './hitstar.service';

@Module({
imports: [HttpModule, ConfigModule],
controllers: [HitstarController],
providers: [HitstarService],
exports: [],
})
export class HitstarModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { SpotifyService } from './spotify.service';
import { HitstarService } from './hitstar.service';

const mockTokenResponse = {
data: {
Expand Down Expand Up @@ -32,14 +32,14 @@ const mockSpotifyResponse = {
},
};

describe('SpotifyService', () => {
let service: SpotifyService;
describe('HitstarService', () => {
let service: HitstarService;
let httpService: HttpService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SpotifyService,
HitstarService,
{
provide: HttpService,
useValue: { get: jest.fn(), post: jest.fn() },
Expand All @@ -51,7 +51,7 @@ describe('SpotifyService', () => {
],
}).compile();

service = module.get<SpotifyService>(SpotifyService);
service = module.get<HitstarService>(HitstarService);
httpService = module.get<HttpService>(HttpService);
});

Expand Down
127 changes: 127 additions & 0 deletions backend/libs/hitstar/src/lib/hitstar.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { SpotifyTrackDto } from '@backend/shared-types';
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';

// Available spotify genres
// acoustic, afrobeat, alt-rock, alternative, ambient, anime, black-metal, bluegrass, blues, bossanova, brazil, breakbeat, british, cantopop, chicago-house, children, chill, classical, club, comedy, country, dance, dancehall, death-metal, deep-house, detroit-techno, disco, disney, drum-and-bass, dub, dubstep, edm, electro, electronic, emo, folk, forro, french, funk, garage, german, gospel, goth, grindcore, groove, grunge, guitar, happy, hard-rock, hardcore, hardstyle, heavy-metal, hip-hop, holidays, honky-tonk, house, idm, indian, indie, indie-pop, industrial, iranian, j-dance, j-idol, j-pop, j-rock, jazz, k-pop, kids, latin, latino, malay, mandopop, metal, metal-misc, metalcore, minimal-techno, movies, mpb, new-age, new-release, opera, pagode, party, philippines-opm, piano, pop, pop-film, post-dubstep, power-pop, progressive-house, psych-rock, punk, punk-rock, r-n-b, rainy-day, reggae, reggaeton, road-trip, rock, rock-n-roll, rockabilly, romance, sad, salsa, samba, sertanejo, show-tunes, singer-songwriter, ska, sleep, songwriter, soul, soundtracks, spanish, study, summer, swedish, synth-pop, tango, techno, trance, trip-hop, turkish, work-out, world-music

@Injectable()
export class HitstarService {
private readonly logger = new Logger(HitstarService.name);
private accessToken: string | null = null;
private tokenExpiresAt = 0;

constructor(
private httpService: HttpService,
private configService: ConfigService,
) {}

private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
return this.accessToken;
}

const clientId = this.configService.get<string>('SPOTIFY_CLIENT_ID');
const clientSecret = this.configService.get<string>(
'SPOTIFY_CLIENT_SECRET',
);
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
'base64',
);

const response = await firstValueFrom(
this.httpService.post(
'https://accounts.spotify.com/api/token',
'grant_type=client_credentials',
{
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
},
),
);

this.accessToken = response.data.access_token as string;
this.tokenExpiresAt =
Date.now() + (response.data.expires_in as number) * 1000;
return this.accessToken;
}

async getRandomTrack(): Promise<SpotifyTrackDto> {
const maxAttempts = 10;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
const offset = Math.floor(Math.random() * 100);
const token = await this.getAccessToken();

this.logger.log(
`Attempt ${attempt + 1}: searching Spotify (offset=${offset})`,
);

try {
const year = 1955 + Math.floor(Math.random() * (2025 - 1955 + 1));
const searchUrl = `https://api.spotify.com/v1/search?q=*+year%3A${year}+genre%3Apop&type=track&market=DE&limit=10&offset=${offset}`;
this.logger.log(`Fetching URL: ${searchUrl}`);
const response = await firstValueFrom(
this.httpService.get(searchUrl, {
headers: { Authorization: `Bearer ${token}` },
}),
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items: any[] = response.data?.tracks?.items ?? [];
const validTracks = items.filter((t) => t.album?.release_date);

this.logger.log(
`Attempt ${attempt + 1}: ${items.length} tracks fetched, ${validTracks.length} usable`,
);

if (validTracks.length > 0) {
const track =
validTracks[Math.floor(Math.random() * validTracks.length)];
const releaseYear = parseInt(
(track.album.release_date as string).substring(0, 4),
10,
);
const albumCover: string = track.album?.images?.[0]?.url ?? '';
const artist: string = (track.artists as { name: string }[])
.map((a) => a.name)
.join(', ');

this.logger.log(
`Found: "${track.name as string}" by ${artist} (${releaseYear})`,
);

return {
id: track.id as string,
name: track.name as string,
artist,
album: track.album.name as string,
albumCover,
releaseYear,
spotifyUrl: (track.external_urls?.spotify as string) ?? '',
previewUrl: track.preview_url as string,
};
}

this.logger.warn(
`Attempt ${attempt + 1}: no usable track found. HTTP ${response.status}, response: ${JSON.stringify(response.data)}`,
);
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response
?.status;
const data = (err as { response?: { data?: unknown } })?.response?.data;
this.logger.warn(
`Attempt ${attempt + 1} failed. HTTP ${status ?? 'n/a'}, response: ${JSON.stringify(data ?? err)}`,
);
}
}

throw new Error(
`Could not find a random track after ${maxAttempts} attempts.`,
);
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './lib/keystore-mongodb.service';
export * from './lib/schema/keystore.schema';
export * from './lib/shared-keystore-persistence.module';
export * from './lib/toggle-seed.config';
export * from './lib/toggle-seed.service';
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { KeystoreMongoDbService } from './keystore-mongodb.service';
import { Keystore, KeystoreSchema } from './schema/keystore.schema';
import { ToggleSeedService } from './toggle-seed.service';

@Module({
imports: [
Expand All @@ -10,7 +11,7 @@ import { Keystore, KeystoreSchema } from './schema/keystore.schema';
]),
],
controllers: [],
providers: [KeystoreMongoDbService],
providers: [KeystoreMongoDbService, ToggleSeedService],
exports: [KeystoreMongoDbService],
})
export class SharedKeystorePersistenceModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const TOGGLE_IDENTIFIER = 'tilloh-toggles';

export interface ToggleSeedEntry {
key: string;
defaultValue: string;
}

export const TOGGLE_SEED_CONFIG: ToggleSeedEntry[] = [
{ key: 'TOGGLE_RANDOM_JOKE', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_MEMORANDUM', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_TODO', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_FOOD_SCAN', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_JOKES', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_CATCH_EM_ALL', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_UNO_SORT', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_ABOUT', defaultValue: 'true' },
{ key: 'TOGGLE_ADMIN_DASHBOARD', defaultValue: 'true' },
{ key: 'TOGGLE_ADMIN_ACTIVITIES', defaultValue: 'true' },
{ key: 'TOGGLE_ADMIN_IDENTIFIERS', defaultValue: 'true' },
{ key: 'TOGGLE_ADMIN_JOKES', defaultValue: 'true' },
{ key: 'TOGGLE_ADMIN_LINK_PRESETS', defaultValue: 'true' },
{ key: 'TOGGLE_NAV_HITSTAR', defaultValue: 'true' },
];
Loading
Loading