Blog

Notes on the .Net ecosystem, Angular, Software Architecture, Machine Learning and CyberSecurity

← All posts
Putting it all together: RockPlayer playing, from like to the next song
  • Angular 22
  • RockPlayer

Putting it all together: RockPlayer playing, from like to the next song

With the onboarding ready in the previous article, the last piece is missing: the screen where the song actually plays, and the person can like, dislike, go forward or go back, without any of that depending on the others. Exactly like Spotify: like and dislike are independent from navigating forward or backward.

Going back to the previous song, with no server at all

Back in article 3, when the topic was Redis, it was settled that going back to the previous song doesn't need any state stored on the server, because Angular already keeps state on its own. This is where that decision turns into code: a simple history, kept in signals, with an index moving forward and backward inside it.

@Component({
  selector: 'app-player',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './player.component.html',
})
export class PlayerComponent implements OnInit {
  private readonly recommendationsService = inject(RecommendationsService);
  private readonly feedbackService = inject(FeedbackService);

  readonly userId = input.required<string>();

  private readonly history = signal<PlaybackSource[]>([]);
  private readonly currentIndex = signal(-1);
  private readonly feedbackGiven = signal<Map<string, boolean>>(new Map());

  readonly errorMessage = signal<string | null>(null);
  readonly current = computed(() => this.history()[this.currentIndex()]);
  readonly canGoBack = computed(() => this.currentIndex() > 0);
  readonly currentFeedback = computed(() => {
    const song = this.current();

    return song ? this.feedbackGiven().get(song.songId) : undefined;
  });

  // not the constructor: userId has no value yet until Angular finishes binding inputs
  ngOnInit() {
    this.next();
  }

  next() {
    if (this.currentIndex() < this.history().length - 1) {
      // already fetched before, just move the pointer, no new request
      this.currentIndex.update((index) => index + 1);
      return;
    }

    this.errorMessage.set(null);
    this.recommendationsService.getNextSong(this.userId()).subscribe({
      next: (source) => {
        this.history.update((items) => [...items, source]);
        this.currentIndex.update((index) => index + 1);
      },
      error: (error: HttpErrorResponse) => this.errorMessage.set(extractErrorMessage(error)),
    });
  }

  back() {
    if (this.canGoBack()) {
      this.currentIndex.update((index) => index - 1);
    }
  }

  react(liked: boolean) {
    const song = this.current();

    if (!song) return;

    this.feedbackService.submit(this.userId(), song.songId, liked).subscribe(() => {
      this.feedbackGiven.update((map) => new Map(map).set(song.songId, liked));
    });
  }
}

next() only fetches a new song from the API when the history runs out, if the person had already gone back, it reuses what is already in memory. back() never calls the API, it just moves the index. And react() sends the feedback to RockPlayerApi, but doesn't touch currentIndex, moving forward remains a choice made by whoever is listening.

During development, two real problems showed up here. userId is a required input, and it only has a value after Angular finishes binding a component's inputs, so calling next() straight from the constructor threw the NG0950 error, it got moved to ngOnInit. And a network failure simply didn't warn anyone, so errorMessage holds the message from the last failure, shown on screen alongside everything else (the same idea goes into the onboarding screen too, with a failure fetching bands or creating the user).

The screen

@if (errorMessage(); as message) {
  <p>{{ message }}</p>
}

@if (current(); as song) {
  @switch (song.kind) {
    @case ('Video') {
      <iframe [src]="song.url | safeUrl" allow="autoplay" allowfullscreen></iframe>
    }
    @case ('AudioWithCover') {
      @if (song.coverImageUrl) {
        <img [src]="song.coverImageUrl" [alt]="song.title" />
      }
      <audio [src]="song.url" controls autoplay></audio>
    }
  }
  <h2>{{ song.title }}</h2>
  <p>{{ song.band }}</p>
  <div class="controls">
    <button (click)="back()" [disabled]="!canGoBack()">Back</button>
    <button (click)="react(false)" [class.active]="currentFeedback() === false">Dislike</button>
    <button (click)="react(true)" [class.active]="currentFeedback() === true">Like</button>
    <button (click)="next()">Next</button>
  </div>
}

The @switch decides between video embed and audio player with cover, even though only YouTube exists for now, enough for a new adapter to come in tomorrow without touching this screen. The iframe's src goes through a safeUrl pipe because Angular blocks external resource URLs by default, it only exists to say that this URL is trusted, with no logic beyond that. The @if around the cover image is there because coverImageUrl is typed as string | null, and Angular's template type checking requires narrowing it before binding it to an img element.

Closing the series

With this, RockPlayer does what it set out to do back in article 1: it takes the recommendation that RockRecommender calculates and puts it to play for real, with like, dislike, next and back, all without needing a database of its own. The full code of both projects, RockPlayerApi and RockPlayerWeb, is open on GitHub, in the RockPlayerApi repository and the RockPlayerWeb repository, for anyone who wants to follow along line by line.

Sources