bitmovin-player-ui
Version:
Bitmovin Player UI Framework
323 lines (281 loc) • 10.3 kB
text/typescript
import { Component, ComponentConfig } from '../Component';
import { EventDispatcher, Event } from '../../EventDispatcher';
import { ArrayUtils } from '../../utils/ArrayUtils';
import { LocalizableText, i18n } from '../../localization/i18n';
/**
* A map of items (key/value -> label} for a {@link ListSelector} in a {@link ListSelectorConfig}.
*/
export interface ListItem {
key: string;
label: LocalizableText;
sortedInsert?: boolean;
ariaLabel?: string;
}
/**
* Filter function that can be used to filter out list items added through {@link ListSelector.addItem}.
*
* This is intended to be used in conjunction with subclasses that populate themselves automatically
* via the player API, e.g. {@link SubtitleSelectBox}.
*/
export interface ListItemFilter {
/**
* Takes a list item and decides whether it should pass or be discarded.
* @param {ListItem} listItem the item to apply the filter to
* @returns {boolean} true to let the item pass through the filter, false to discard the item
*/
(listItem: ListItem): boolean;
}
/**
* Translator function to translate labels of list items added through {@link ListSelector.addItem}.
*
* This is intended to be used in conjunction with subclasses that populate themselves automatically
* via the player API, e.g. {@link SubtitleSelectBox}.
*/
export interface ListItemLabelTranslator {
/**
* Takes a list item, optionally changes the label, and returns the new label.
* @param {ListItem} listItem the item to translate
* @returns {string} the translated or original label
*/
(listItem: ListItem): string;
}
/**
* Configuration interface for a {@link ListSelector}.
*
* @category Configs
*/
export interface ListSelectorConfig extends ComponentConfig {
items?: ListItem[];
filter?: ListItemFilter;
translator?: ListItemLabelTranslator;
}
export abstract class ListSelector<Config extends ListSelectorConfig> extends Component<ListSelectorConfig> {
protected items: ListItem[];
protected selectedItem: string | null = null;
private listSelectorEvents = {
onItemAdded: new EventDispatcher<ListSelector<Config>, string>(),
onItemRemoved: new EventDispatcher<ListSelector<Config>, string>(),
onItemSelected: new EventDispatcher<ListSelector<Config>, string>(),
onItemSelectionChanged: new EventDispatcher<ListSelector<Config>, string>(),
};
constructor(config: ListSelectorConfig = {}) {
super(config);
this.config = this.mergeConfig(
config,
{
items: [],
cssClass: 'ui-listselector',
},
this.config,
);
this.items = this.config.items;
}
private getItemIndex(key: string): number {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].key === key) {
return i;
}
}
return -1;
}
/**
* Returns all current items of this selector.
* * @returns {ListItem[]}
*/
getItems(): ListItem[] {
return this.items;
}
/**
* Checks if the specified item is part of this selector.
* @param key the key of the item to check
* @returns {boolean} true if the item is part of this selector, else false
*/
hasItem(key: string): boolean {
return this.getItemIndex(key) > -1;
}
/**
* Adds an item to this selector by doing a sorted insert or by appending the element to the end of the list of items.
* If an item with the specified key already exists, it is replaced.
* @param key the key of the item to add
* @param label the (human-readable) label of the item to add
* @param sortedInsert whether the item should be added respecting the order of keys
* @param ariaLabel custom aria label for the listItem
*/
addItem(key: string | null, label: LocalizableText, sortedInsert = false, ariaLabel = '') {
const listItem = { key: key, label: i18n.performLocalization(label), ...(ariaLabel && { ariaLabel }) };
// Apply filter function
if (this.config.filter && !this.config.filter(listItem)) {
return;
}
// Apply translator function
if (this.config.translator) {
listItem.label = this.config.translator(listItem);
}
// Try to remove key first to get overwrite behavior and avoid duplicate keys
this.removeItem(key); // This will trigger an ItemRemoved and an ItemAdded event
// Add the item to the list
if (sortedInsert) {
const index = this.items.findIndex(entry => entry.key > key);
if (index < 0) {
this.items.push(listItem);
} else {
this.items.splice(index, 0, listItem);
}
} else {
this.items.push(listItem);
}
this.onItemAddedEvent(key);
}
/**
* Removes an item from this selector.
* @param key the key of the item to remove
* @returns {boolean} true if removal was successful, false if the item is not part of this selector
*/
removeItem(key: string): boolean {
const index = this.getItemIndex(key);
if (index > -1) {
ArrayUtils.remove(this.items, this.items[index]);
this.onItemRemovedEvent(key);
return true;
}
return false;
}
/**
* Selects an item from the items in this selector.
*
* This represents an actual value change in the UI state. It should be used when the
* selection is updated based on the current player/component state (e.g. from a player event),
* not as a user-intent signal.
*
* @param key the key of the item to select
* @returns {boolean} true is the selection was successful, false if the selected item is not part of the selector
*/
selectItem(key: string): boolean {
if (key === this.selectedItem) {
// itemConfig is already selected, suppress any further action
return true;
}
const index = this.getItemIndex(key);
if (index > -1) {
this.selectedItem = key;
this.onItemSelectedEvent(key);
return true;
}
return false;
}
/**
* Returns the key of the selected item.
* @returns {string} the key of the selected item or null if no item is selected
*/
getSelectedItem(): string | null {
return this.selectedItem;
}
/**
* Returns the items for the given key or undefined if no item with the given key exists.
* @param key the key of the item to return
* @returns {ListItem} the item with the requested key. Undefined if no item with the given key exists.
*/
getItemForKey(key: string): ListItem | null {
return this.items.find(item => item.key === key);
}
/**
* Synchronize the current items of this selector with the given ones. This will remove and add items selectively.
* For each removed item the ItemRemovedEvent and for each added item the ItemAddedEvent will be triggered. Favour
* this method over using clearItems and adding all items again afterwards.
* @param newItems
*/
synchronizeItems(newItems: ListItem[]): void {
newItems
.filter(item => !this.hasItem(item.key))
.forEach(item => this.addItem(item.key, item.label, item.sortedInsert, item.ariaLabel));
this.items
.filter(item => newItems.filter(i => i.key === item.key).length === 0)
.forEach(item => this.removeItem(item.key));
}
/**
* Removes all items from this selector.
*/
clearItems() {
// local copy for iteration after clear
const items = this.items;
// clear items
this.items = [];
// clear the selection as the selected item is also removed
this.selectedItem = null;
// fire events
for (const item of items) {
this.onItemRemovedEvent(item.key);
}
}
/**
* Returns the number of items in this selector.
* @returns {number}
*/
itemCount(): number {
return Object.keys(this.items).length;
}
protected onItemAddedEvent(key: string) {
this.listSelectorEvents.onItemAdded.dispatch(this, key);
}
protected onItemRemovedEvent(key: string) {
this.listSelectorEvents.onItemRemoved.dispatch(this, key);
}
protected onItemSelectedEvent(key: string) {
this.listSelectorEvents.onItemSelected.dispatch(this, key);
}
protected onItemSelectionChangedEvent(key: string) {
this.listSelectorEvents.onItemSelectionChanged.dispatch(this, key);
}
/**
* Dispatches a selection-changed event and optionally updates the selected item.
*
* This is the entry point for user-driven interactions. It exists separately from {@link selectItem}
* so we can distinguish intent (user interaction that should call into the e.g. player or other components)
* from actual value changes (state updates originating from the player).
*
* @param key the key of the item to select
* @param updateSelectedItem when true, updates the selected item
*/
dispatchItemSelectionChanged(key: string, updateSelectedItem: boolean = true): void {
if (updateSelectedItem) {
this.selectItem(key);
}
this.onItemSelectionChangedEvent(key);
}
/**
* Gets the event that is fired when an item is added to the list of items.
* @returns {Event<ListSelector<Config>, string>}
*/
get onItemAdded(): Event<ListSelector<Config>, string> {
return this.listSelectorEvents.onItemAdded.getEvent();
}
/**
* Gets the event that is fired when an item is removed from the list of items.
* @returns {Event<ListSelector<Config>, string>}
*/
get onItemRemoved(): Event<ListSelector<Config>, string> {
return this.listSelectorEvents.onItemRemoved.getEvent();
}
/**
* Gets the event that is fired when the selected item value changes.
*
* Use this to react to actual value changes (e.g. player state updates). This should not
* trigger new player calls to avoid feedback loops.
*
* @returns {Event<ListSelector<Config>, string>}
*/
get onItemSelected(): Event<ListSelector<Config>, string> {
return this.listSelectorEvents.onItemSelected.getEvent();
}
/**
* Gets the event that is fired when a selection change is requested.
*
* Use this to react to user interaction and call into the player or other components.
* It intentionally does not represent a confirmed value change.
*
* @returns {Event<ListSelector<Config>, string>}
*/
get onItemSelectionChanged(): Event<ListSelector<Config>, string> {
return this.listSelectorEvents.onItemSelectionChanged.getEvent();
}
}