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) showsitem.showwhen the item is a TV episode, otherwiseitem.title. - The secondary line (
showInfoText) isS{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:
| Field | Type | Meaning |
|---|---|---|
title | string | undefined | Movie title, or episode title for TV |
show | string | undefined | Series name (present only for TV content) |
season | number | undefined | Season number (1-based; 0 = specials/extras) |
episode | number | undefined | Episode 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.
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: 0sotext-overflow: truncateworks inside a flex parent. - Both spans should be
display: block(or use a block flex column) so they stack vertically. - The secondary span starts
hiddenand is shown only when it has content.
.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.
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:
| Member | Type | Purpose |
|---|---|---|
overlay | HTMLDivElement | Root overlay element |
topBar | HTMLDivElement | Top horizontal strip |
titleText | HTMLSpanElement | Primary title line |
showInfoText | HTMLSpanElement | Secondary episode info line |
bottomBar | HTMLDivElement | Bottom controls area |
playButton | HTMLButtonElement | Play/pause toggle (Step 3) |
seekBar | HTMLInputElement | Seek slider (Step 4) |
volumeContainer | HTMLDivElement | Volume area (Step 5) |