Skip to content

Commit

Permalink
feat: combined menu component
Browse files Browse the repository at this point in the history
  • Loading branch information
alexarassat committed May 24, 2024
1 parent 22ef920 commit e9484ee
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 0 deletions.
36 changes: 36 additions & 0 deletions packages/combined-menu-control/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { install } from "../../shared/install"
import { type Player } from "@flowplayer/player"
import { type FlowplayerMenu, MenuDialog } from "./menuDialog"

export default class CombinedMenuControl extends HTMLElement{
dialog: MenuDialog

constructor(player: Player) {
super()
this.classList.add("fp-controls", "fp-togglable")

// default components
this.append(...player.createComponents(
flowplayer.defaultElements.CONTROL_BUTTONS
, flowplayer.defaultElements.LIVE_STATUS
, flowplayer.defaultElements.ELAPSED
, flowplayer.defaultElements.TIMELINE
, flowplayer.defaultElements.CONTROL_DURATION
, flowplayer.defaultElements.VOLUME_CONTROL
))

// menu dialog
window.customElements.define("flowplayer-menu-dialog", MenuDialog)
this.dialog = new (window.customElements.get("flowplayer-menu-dialog") as CustomElementConstructor)(player) as MenuDialog
this.append(this.dialog)
}

append(...nodes: (Node | string)[]) {
// append menus to the menu dialog.
nodes.forEach((node) => {
(node !== this.dialog && (node as Element)?.querySelector(".fp-menu")) ? this.dialog?.onSubMenuCreated(node as FlowplayerMenu) : super.append(node)
})
}
}

install("flowplayer-control", "combined-menu-controls", CombinedMenuControl)
28 changes: 28 additions & 0 deletions packages/combined-menu-control/menu.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.fp-menu-dialog summary {
list-style: none;
width: 1.2em;
height:1.2em;
filter: drop-shadow(0 0 2px rgba(0,0,0,0.4));
background-repeat: no-repeat;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='white' viewBox='0 0 512 512'%3E%3Cpath d='M256 0c17 0 33.6 1.7 49.8 4.8c7.9 1.5 21.8 6.1 29.4 20.1c2 3.7 3.6 7.6 4.6 11.8l9.3 38.5C350.5 81 360.3 86.7 366 85l38-11.2c4-1.2 8.1-1.8 12.2-1.9c16.1-.5 27 9.4 32.3 15.4c22.1 25.1 39.1 54.6 49.9 86.3c2.6 7.6 5.6 21.8-2.7 35.4c-2.2 3.6-4.9 7-8 10L459 246.3c-4.2 4-4.2 15.5 0 19.5l28.7 27.3c3.1 3 5.8 6.4 8 10c8.2 13.6 5.2 27.8 2.7 35.4c-10.8 31.7-27.8 61.1-49.9 86.3c-5.3 6-16.3 15.9-32.3 15.4c-4.1-.1-8.2-.8-12.2-1.9L366 427c-5.7-1.7-15.5 4-16.9 9.8l-9.3 38.5c-1 4.2-2.6 8.2-4.6 11.8c-7.7 14-21.6 18.5-29.4 20.1C289.6 510.3 273 512 256 512s-33.6-1.7-49.8-4.8c-7.9-1.5-21.8-6.1-29.4-20.1c-2-3.7-3.6-7.6-4.6-11.8l-9.3-38.5c-1.4-5.8-11.2-11.5-16.9-9.8l-38 11.2c-4 1.2-8.1 1.8-12.2 1.9c-16.1 .5-27-9.4-32.3-15.4c-22-25.1-39.1-54.6-49.9-86.3c-2.6-7.6-5.6-21.8 2.7-35.4c2.2-3.6 4.9-7 8-10L53 265.7c4.2-4 4.2-15.5 0-19.5L24.2 218.9c-3.1-3-5.8-6.4-8-10C8 195.3 11 181.1 13.6 173.6c10.8-31.7 27.8-61.1 49.9-86.3c5.3-6 16.3-15.9 32.3-15.4c4.1 .1 8.2 .8 12.2 1.9L146 85c5.7 1.7 15.5-4 16.9-9.8l9.3-38.5c1-4.2 2.6-8.2 4.6-11.8c7.7-14 21.6-18.5 29.4-20.1C222.4 1.7 239 0 256 0zM218.1 51.4l-8.5 35.1c-7.8 32.3-45.3 53.9-77.2 44.6L97.9 120.9c-16.5 19.3-29.5 41.7-38 65.7l26.2 24.9c24 22.8 24 66.2 0 89L59.9 325.4c8.5 24 21.5 46.4 38 65.7l34.6-10.2c31.8-9.4 69.4 12.3 77.2 44.6l8.5 35.1c24.6 4.5 51.3 4.5 75.9 0l8.5-35.1c7.8-32.3 45.3-53.9 77.2-44.6l34.6 10.2c16.5-19.3 29.5-41.7 38-65.7l-26.2-24.9c-24-22.8-24-66.2 0-89l26.2-24.9c-8.5-24-21.5-46.4-38-65.7l-34.6 10.2c-31.8 9.4-69.4-12.3-77.2-44.6l-8.5-35.1c-24.6-4.5-51.3-4.5-75.9 0zM208 256a48 48 0 1 0 96 0 48 48 0 1 0 -96 0zm48 96a96 96 0 1 1 0-192 96 96 0 1 1 0 192z'/%3E%3C/svg%3E");
}

.fp-back-button {
display: block !important;
}

.flowplayer .fp-menu-dialog .fp-menu.fp-submenu .fp-back-button {
display: none !important;
}

.fp-menu-dialog .fp-menu-header {
padding: .9em 5.7em;
}

.fp-menu-dialog .fp-subtitles-menu {
left: 1.5em;
}

.fp-menu.is-close {
display: none !important;
}
246 changes: 246 additions & 0 deletions packages/combined-menu-control/menuDialog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import {type Player} from "@flowplayer/player"

export type FlowplayerMenu = HTMLElement & {
menu: HTMLDivElement
menuHeader: HTMLElement
}

enum FlowplayerSubtitlesMenuState {
main = 0
, tracks = 1
, style = 2
, styleOpt = 3
}

enum MenuType {
asel = 0
, subtitles = 1
, qsel = 2
, vtsel = 3
, speed = 4
}

type FlowplayerSubtitlesMenu = FlowplayerMenu & {
createMenu: (state: FlowplayerSubtitlesMenuState)=> void
}

const MENU_CLASS = "fp-menu"
, MENU_CONTAINER_CLASS = "fp-menu-container"
, MENU_HEADER_CLASS = "fp-menu-header"
, MENU_CLOSE_ICON_CLASS = "fp-close"
, MENU_CLOSE = "is-close"

export class MenuDialog extends HTMLElement {
menuContainer: HTMLDetailsElement
summaryEle: HTMLElement
mainMenu: HTMLDivElement
menuHeader: HTMLDivElement
menuTitle: HTMLHeadElement
olEle: HTMLOListElement
closeEle: HTMLSpanElement

player

constructor(player: Player) {
super()
this.className = "fp-menu-dialog"
this.player = player

this.menuContainer = document.createElement("details")
this.summaryEle = document.createElement("summary")
this.menuHeader = document.createElement("div")
this.menuTitle = document.createElement("h3")
this.olEle = document.createElement("ol")
this.mainMenu = document.createElement("div")
this.closeEle = document.createElement("span")

this.menuHeader.classList.add(MENU_HEADER_CLASS)
this.menuHeader.append(this.menuTitle, this.closeEle)

this.mainMenu.classList.add(MENU_CLASS, "fp-main-menu")
this.mainMenu.append(this.menuHeader, this.olEle)

this.closeEle.classList.add(MENU_CLOSE_ICON_CLASS)
this.closeEle.textContent = "×"

this.menuContainer.classList.add(MENU_CONTAINER_CLASS)
this.menuContainer.append(this.summaryEle, this.mainMenu)

//TODO add settings translation
this.menuTitle.textContent = this.player.i18n("core.settings", "Settings")

//Accessibility
this.olEle.setAttribute("aria-labelledby", this.summaryEle.id)
this.olEle.setAttribute("role", "menu")
this.summaryEle.setAttribute("aria-haspopup", "true")
this.summaryEle.setAttribute("aria-controls", this.olEle.id)
this.summaryEle.setAttribute("tabindex", "0")
this.summaryEle.setAttribute("aria-expanded", "false")
this.summaryEle.setAttribute("role", "button")
//TODO add translation
this.summaryEle.setAttribute("aria-label", "Settings")

this.append(this.menuContainer)
this.toggleVisibility()

//listeners
player.on("keyboard:close:menus", this.onKeyboardCloseMenu.bind(this))
player.root.addEventListener("click", this.onRootClick.bind(this))
this.addEventListener("click", this.onMenuClick.bind(this));
["focusin", "focusout"].forEach((ev)=> this.mainMenu.addEventListener(ev as any, this.onFocus.bind(this)))
}

onSubMenuCreated(menuComponent: FlowplayerMenu | FlowplayerSubtitlesMenu) {
this.createDialogOpt(menuComponent)
this.addSubMenuBackButton(menuComponent.menuHeader)
this.menuContainer.append(menuComponent.menu)
}

//create new main-menu opt for a submenu
createDialogOpt(menuComponent: FlowplayerMenu | FlowplayerSubtitlesMenu) {
const opt = document.createElement("li")
opt.textContent = menuComponent.menuHeader.querySelector("h3")?.textContent || ""

const type = this.detectSubMenuType(menuComponent.classList) as MenuType
this.toggleDialogOpt(menuComponent.menu, opt, type)

menuComponent.addEventListener(this.findSubMenuOptionsEvent(type) as any, ()=> {
this.toggleDialogOpt(menuComponent.menu, opt, type)
})

opt.onclick = type === MenuType.subtitles
? this.onSubtitlesOptClick.bind(this, menuComponent as FlowplayerSubtitlesMenu)
: this.onOptClick.bind(this, menuComponent)

//accessibility
opt.setAttribute("role", "menuitem")
opt.setAttribute("aria-selected", "false")
opt.setAttribute("tabindex", "0")
opt.setAttribute("aria-haspopup", "true")
opt.setAttribute("aria-label", menuComponent.menuHeader.querySelector("h3")?.textContent || "")
}

// remove/append dialog opt based on the number of the submenu opts
toggleDialogOpt(submenu: HTMLDivElement, dialogOpt: HTMLLIElement, type?: MenuType) {
try {
submenu.querySelectorAll("li").length > (type === MenuType.subtitles ? 0 : 1)
? this.olEle.appendChild(dialogOpt)
: this.olEle.removeChild(dialogOpt)
} catch (e) { }

this.toggleVisibility()
}

// adds a back button to the header of a submenu
addSubMenuBackButton(menuHeader: HTMLElement) {
const back_button = document.createElement("div")
back_button.className = "fp-icon fp-menu-back fp-back-button"
back_button.setAttribute("aria-hidden", "true")
menuHeader.append(back_button)
}

// opens a menu and hide the rest of dialog menus
openMenu(menu_to_open: HTMLElement){
this.querySelectorAll(".fp-menu")?.forEach((menu)=> {
if (menu === menu_to_open) return
(menu as HTMLElement)?.classList.add(MENU_CLOSE)
})

menu_to_open.classList.remove(MENU_CLOSE)
menu_to_open.querySelector("li")?.focus()
}

//open/close dialog
toggleMenuDialog(open: boolean) {
this.menuContainer.open = open
// TODO replace has-open-menu state, with flowplayer Constant
this.player.root.classList.toggle("has-menu-opened" , open)
this.summaryEle.setAttribute("aria-expanded", open + "")
}

//hide/show dialog if there are no available options to any of the submenus
toggleVisibility() {
const hide = !this.olEle.querySelectorAll("li").length
this.style.setProperty("display", hide ? "none": "block")
// close menu dialog
if (hide && this.menuContainer.open) this.toggleMenuDialog(false)
}

//dialog's click listener
onMenuClick(ev: MouseEvent){
if (ev.defaultPrevented) return
ev.preventDefault()

const target = ev.target as HTMLElement
if (target?.classList?.contains("fp-menu-back") || target?.closest("li")) return this.openMenu(this.mainMenu)

const should_open = !this.menuContainer.open
if (should_open) this.openMenu(this.mainMenu)
this.toggleMenuDialog(should_open)
}

//dialog's focus listener
onFocus(ev: FocusEvent) {
const target = ev.target
if (!(target instanceof HTMLLIElement)) return
target.setAttribute("aria-selected", ev.type === "focusin" ? "true" : "false")
}

//main menu's opt click listener
onOptClick(menuComponent: FlowplayerMenu, ev: MouseEvent) {
ev.preventDefault()
this.openMenu(menuComponent.menu)
}

//main menu's subtitle opt click listener
onSubtitlesOptClick(menuComponent: FlowplayerSubtitlesMenu, ev: MouseEvent) {
//Create main-subtitles-menu before opening subtitles menu
if (!this.player.opt("subtitles.native")) menuComponent.createMenu(0)
this.onOptClick(menuComponent, ev)
}

//player's root click listener
onRootClick(ev: MouseEvent) {
if (ev.composedPath().includes(this)) return
this.toggleMenuDialog(false)
}

onKeyboardCloseMenu(ev: Event) {
if (ev.defaultPrevented) return
ev.preventDefault()

if (this.mainMenu.classList.contains(MENU_CLOSE)) return this.openMenu(this.mainMenu)
this.toggleMenuDialog(false)
this.summaryEle.focus()
}

detectSubMenuType(classList: DOMTokenList) {
//audio-menu
if (classList.contains("fp-asel")) return MenuType.asel
//quality menu
if (classList.contains("fp-qsel")) return MenuType.qsel
//speed menu
if (classList.contains("fp-speed")) return MenuType.speed
//subtitles menu
if (classList.contains("fp-cc")) return MenuType.subtitles
//video tracks menu
if (classList.contains("fp-vsel")) return MenuType.vtsel
}

findSubMenuOptionsEvent(type: MenuType) {
switch (type) {
case MenuType.asel:
return "audio:tracks"
case MenuType.qsel:
return "quality:tracks"
case MenuType.speed:
return "speed:options"
case MenuType.subtitles:
return "subs:tracks"
case MenuType.vtsel:
return "video:tracks"
default:
return ""
}
}
}
10 changes: 10 additions & 0 deletions packages/combined-menu-control/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@flowplayer/components-combined-menu-controls",
"main": "./index.ts",
"description": "A control bar with a single combined menu for all settings",
"flowplayer": {
"componentName": "combined-menu-controls",
"overridenComponent": "flowplayer-control",
"className": "CombinedMenuControl"
}
}

0 comments on commit e9484ee

Please sign in to comment.