Skip to content

Step 6: Title Bar

The top bar sits above the video and shows the viewer what they are watching. For a movie it shows the title. For a TV episode it shows the series name on the primary line and the season, episode number, and episode title on the secondary line, exactly the two-line layout the built-in DesktopUiPlugin uses internally.

This step builds those two elements, mounts them in the overlay, and keeps them updated as the queue advances.

What the real top bar does

The DesktopUiPlugin builds its top bar in topBar.ts. Two key facts from that source:

  • The primary line (titleText) shows item.show when the item is a TV episode, otherwise item.title.
  • The secondary line (showInfoText) is S{n}E{n} • {episodeTitle} for a regular season, Extras E{n} • {episodeTitle} for season-0 specials, and hidden entirely for movies.

Your custom plugin will use the same logic, reading those fields directly from the typed VideoPlaylistItem.

Playlist item fields involved

VideoPlaylistItem carries these fields for the title bar:

FieldTypeMeaning
titlestring | undefinedMovie title, or episode title for TV
showstring | undefinedSeries name (present only for TV content)
seasonnumber | undefinedSeason number (1-based; 0 = specials/extras)
episodenumber | undefinedEpisode number within the season

When show is absent the item is treated as a standalone movie and only title is shown.

Add the title bar to your plugin

You already have a topBar element from Step 4. Add two new private fields and one helper to your plugin class.

TypeScript
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type { NMVideoPlayer, VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';

export class PlayerUiPlugin extends Plugin<NMVideoPlayer> {
static readonly id = 'player-ui';
static readonly description = 'Tutorial custom UI';

// -- existing refs from earlier steps --
private overlay!: HTMLDivElement;
private topBar!: HTMLDivElement;
private bottomBar!: HTMLDivElement;

// -- new: title bar elements --
private titleText!: HTMLSpanElement;
private showInfoText!: HTMLSpanElement;

use(): void {
this.buildOverlay();
this.buildTopBar();
this.buildBottomBar();

// Populate immediately with whatever is already loaded.
this.updateTitleBar(this.player.item());

// Keep in sync as the queue advances.
this.on('current', ({ item }) => this.updateTitleBar(item));
}

dispose(): void {}

// ... existing methods from steps 1-5 ...

private buildTopBar(): void {
this.topBar = this.player
.createElement('div', 'top-bar')
.addClasses(['top-bar', 'flex', 'items-start', 'justify-end', 'px-4', 'pt-3', 'gap-1'])
.appendTo(this.overlay)
.get();

this.buildTitleDisplay();
}

private buildTitleDisplay(): void {
const titleGroup = this.player
.createElement('div', 'title-group')
.addClasses(['flex', 'flex-col', 'min-w-0'])
.appendTo(this.topBar)
.get();

this.titleText = this.player
.createElement('span', 'title-text')
.addClasses(['text-white', 'text-sm', 'font-semibold', 'truncate', 'leading-tight'])
.appendTo(titleGroup)
.get();

this.showInfoText = this.player
.createElement('span', 'show-info-text')
.addClasses(['text-white/70', 'text-xs', 'truncate', 'leading-tight'])
.appendTo(titleGroup)
.get();

// Hidden until there is secondary content to show.
this.showInfoText.hidden = true;
}

private updateTitleBar(item: VideoPlaylistItem | undefined): void {
if (!item) {
this.titleText.textContent = '';
this.showInfoText.textContent = '';
this.showInfoText.hidden = true;
return;
}

const show = item.show?.trim() ?? '';
const rawTitle = item.title?.trim() ?? '';
const hasShow = show.length > 0;
const hasEpisode = typeof item.episode === 'number';
const seasonNum = typeof item.season === 'number' ? item.season : null;
const episodeNum = typeof item.episode === 'number' ? item.episode : null;

// Primary line: series name for TV content, otherwise the movie title.
this.titleText.textContent = hasShow ? show : rawTitle;

// Secondary line: episode label and episode title for TV content.
let secondary = '';
if (hasShow && hasEpisode) {
const epTitle = rawTitle && rawTitle !== show ? rawTitle : '';
if (seasonNum !== null && seasonNum > 0) {
const label = `S${seasonNum}E${episodeNum}`;
secondary = epTitle ? `${label}${epTitle}` : label;
} else if (seasonNum === 0) {
const label = `Extras E${episodeNum}`;
secondary = epTitle ? `${label}${epTitle}` : label;
} else {
secondary = epTitle ? `A${episodeNum}${epTitle}` : `A${episodeNum}`;
}
}

this.showInfoText.textContent = secondary;
this.showInfoText.hidden = secondary.length === 0;
}
}

Note: The bullet character between the episode label and title is a Unicode middle dot (). Inline string literals work fine here; avoid raw in source if your editor or build chain mangles non-ASCII literals.

Wiring through the 'current' event

The 'current' event fires every time the queue moves to a new item. Its payload is { item: VideoPlaylistItem | undefined; index: number }.

this.on(...) inside use() registers a lifecycle-tracked listener, so it is removed automatically when the plugin disposes, no manual cleanup needed.

Calling this.updateTitleBar(this.player.item()) before registering the listener handles the case where the player was already loaded before the plugin ran. NMVideoPlayer satisfies WithCurrentItem<VideoPlaylistItem> structurally, so item() is available on this.player when the plugin is typed against the concrete class. If you type against the interface (IVideoPlayer & WithCurrentItem<VideoPlaylistItem>), the same call works without a cast.

Styling note

The class list above uses Tailwind utilities. If you are not using Tailwind, replace them with plain CSS. The structural rules that matter are:

  • The title group needs min-width: 0 so text-overflow: truncate works inside a flex parent.
  • Both spans should be display: block (or use a block flex column) so they stack vertically.
  • The secondary span starts hidden and is shown only when it has content.
CSS
.title-group {
display: flex;
flex-direction: column;
min-width: 0;
}

.title-text {
color: #fff;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}

.show-info-text {
color: rgba(255, 255, 255, 0.7);
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}

Testing with movie vs. TV items

Use a playlist with both types to verify the two rendering paths.

TypeScript
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import type { VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';
import { PlayerUiPlugin } from './player-ui-plugin';

const items: VideoPlaylistItem[] = [
{
// Movie: only title is shown.
id: 'sintel',
title: 'Sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
image: 'https://image.tmdb.org/t/p/w780/q2bVM5z90tCGbmXYtq2J38T5hSX.jpg',
duration: 888,
},
{
// TV episode: primary = show name, secondary = S1E1 label + episode title.
id: 'bbb-ep1',
title: 'Part One',
show: 'Big Buck Bunny',
season: 1,
episode: 1,
url: '/Big.Buck.Bunny.(2008)/Big.Buck.Bunny.(2008).NoMercy.m3u8',
image: 'https://image.tmdb.org/t/p/w780/6KErczPBROQty7QoIsaa6wJYXZi.jpg',
duration: 596,
},
];

const player = nmplayer('player')
.addPlugin(PlayerUiPlugin)
.setup({
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
playlist: items,
});

player.on('ready', () => {
player.item(0, { autoplay: true });
});

Load the first item and you should see “Sintel” on the primary line with nothing below it. Advance to the second item and you should see “Big Buck Bunny” on the primary line and “S1E1 • Part One” on the secondary line.

Full plugin state at this step

After this step your plugin class has these private members:

MemberTypePurpose
overlayHTMLDivElementRoot overlay element
topBarHTMLDivElementTop horizontal strip
titleTextHTMLSpanElementPrimary title line
showInfoTextHTMLSpanElementSecondary episode info line
bottomBarHTMLDivElementBottom controls area
playButtonHTMLButtonElementPlay/pause toggle (Step 3)
seekBarHTMLInputElementSeek slider (Step 4)
volumeContainerHTMLDivElementVolume area (Step 5)

Next: Step 7: Fullscreen and Playback Speed