Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic controls to the player #12

Merged
merged 7 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/playback/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ export class Player {
return this.#catalog.tracks.filter(Catalog.isAudioTrack).map((track) => track.name)
}

isPaused() {
return this.#paused
}

async switchTrack(trackname: string) {
const currentTrack = this.getCurrentTrack()
if (this.#paused) {
Expand Down
28 changes: 28 additions & 0 deletions web/src/components/play-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
type PlayButtonProps = {
onClick: () => void
isPlaying: boolean
}

export const PlayButton = (props: PlayButtonProps) => {
return (
<button
class="
absolute bottom-0 left-4 flex h-8 w-12 items-center justify-center
rounded bg-black/70 px-2 py-2 shadow-lg
hover:bg-black/80 focus:bg-black/100 focus:outline-none
"
onClick={() => void props.onClick()}
aria-label={props.isPlaying ? "Pause" : "Play"}
>
{props.isPlaying ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#fff" class="h-6 w-6">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#fff" class="h-4 w-4">
<path d="M3 22v-20l18 10-18 10z" />
</svg>
)}
</button>
)
}
80 changes: 80 additions & 0 deletions web/src/components/track-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createEffect, createSignal } from "solid-js"

type TrackSelectProps = {
trackNum: number
getVideoTracks: () => string[] | undefined
switchTrack: (track: string) => void
}

export const TrackSelect = (props: TrackSelectProps) => {
const [options, setOptions] = createSignal<string[]>([])
const [selectedOption, setSelectedOption] = createSignal<string | undefined>()
const [showTracks, setShowTracks] = createSignal(false)

const handleTrackChange = (track: string) => {
setSelectedOption(track)
props.switchTrack(track)
setShowTracks(false)
}

function updateURLWithTracknumber(trackIndex: number) {
const url = new URL(window.location.href)
url.searchParams.set("track", trackIndex.toString())
window.history.replaceState({}, "", decodeURIComponent(url.toString()))
}

createEffect(() => {
const videotracks = props.getVideoTracks()
if (!videotracks?.length) return

setOptions(videotracks)

const trackNumber = props.trackNum

if (trackNumber >= 0 && trackNumber < videotracks.length) {
const selectedTrack = videotracks[trackNumber]
setSelectedOption(selectedTrack)
updateURLWithTracknumber(trackNumber)
}
})
return (
<>
<button
class="
flex h-4 w-0 items-center justify-center rounded bg-transparent
p-4 text-white hover:bg-black/100
focus:bg-black/80 focus:outline-none
"
aria-label="Select Track"
onClick={() => setShowTracks((prev) => !prev)}
>
⚙️
</button>
{showTracks() && (
<ul class="absolute bottom-6 right-0 mt-2 w-40 rounded bg-black/80 p-0 text-white shadow-lg">
{options()?.length ? (
options().map((option) => (
<li
role="menuitem"
tabIndex={0}
class={`flex w-full cursor-pointer items-center justify-between px-4 py-2 hover:bg-black/100 ${
selectedOption() === option ? "bg-blue-500 text-white" : ""
}`}
onClick={() => handleTrackChange(option)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTrackChange(option)
}
}}
>
<span>{option}</span>
</li>
))
) : (
<li class="cursor-not-allowed px-4 py-2 text-gray-500">No options available</li>
)}
</ul>
)}
</>
)
}
29 changes: 29 additions & 0 deletions web/src/components/volume.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createSignal } from "solid-js"

type VolumeButtonProps = {
mute: (isMuted: boolean) => void
}

export const VolumeButton = (props: VolumeButtonProps) => {
const [isMuted, setIsMuted] = createSignal(false)

const toggleMute = () => {
const newIsMuted = !isMuted()
setIsMuted(newIsMuted)
props?.mute(newIsMuted)
}

return (
<button
class="
flex h-4 w-0 items-center justify-center rounded bg-transparent
p-4 text-white hover:bg-black/80
focus:bg-black/80 focus:outline-none
"
onClick={toggleMute}
aria-label={isMuted() ? "Unmute" : "Mute"}
>
{isMuted() ? "🔇" : "🔊"}
</button>
)
}
144 changes: 64 additions & 80 deletions web/src/components/watch.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { Player } from "@kixelated/moq/playback"

import Fail from "./fail"

import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { VolumeButton } from "./volume"
import { PlayButton } from "./play-button"
import { TrackSelect } from "./track-select"

export default function Watch(props: { name: string }) {
// Use query params to allow overriding environment variables.
const urlSearchParams = new URLSearchParams(window.location.search)
const params = Object.fromEntries(urlSearchParams.entries())
const server = params.server ?? import.meta.env.PUBLIC_RELAY_HOST
let tracknum: number = Number(params.track ?? 0)

const [error, setError] = createSignal<Error | undefined>()

const tracknum: number = Number(params.track ?? 0)
let canvas!: HTMLCanvasElement

const [usePlayer, setPlayer] = createSignal<Player | undefined>()
const [error, setError] = createSignal<Error | undefined>()
const [player, setPlayer] = createSignal<Player | undefined>()
const [isPlaying, setIsPlaying] = createSignal(!player()?.isPaused())
const [showCatalog, setShowCatalog] = createSignal(false)

const [options, setOptions] = createSignal<string[]>([])
const [mute, setMute] = createSignal<boolean>(false)
const [selectedOption, setSelectedOption] = createSignal<string | undefined>()
const [hovered, setHovered] = createSignal(false)
const [showControls, setShowControls] = createSignal(true)

createEffect(() => {
const namespace = props.name
Expand All @@ -34,99 +32,85 @@ export default function Watch(props: { name: string }) {
Player.create({ url, fingerprint, canvas, namespace }, tracknum).then(setPlayer).catch(setError)
})

createEffect(() => {
const player = usePlayer()
if (!player) return

onCleanup(() => player.close())
player.closed().then(setError).catch(setError)
})
const mute = (state: boolean) => {
player()?.mute(state).catch(setError)
}

const play = () => {
usePlayer()?.play().catch(setError)
const switchTrack = (track: string) => {
void player()?.switchTrack(track)
}

const handlePlayPause = async () => {
const player = usePlayer();
if (!player) return;
const getVideoTracks = (): string[] | undefined => {
return player()?.getVideoTracks()
}

try {
await player.play();
} catch (error) {
setError();
const handlePlayPause = () => {
const playerInstance = player()
if (!playerInstance) return

if (playerInstance.isPaused()) {
playerInstance
.play()
.then(() => setIsPlaying(true))
.catch(setError)
} else {
playerInstance
.play()
.then(() => setIsPlaying(false))
.catch(setError)
}
};
}

// The JSON catalog for debugging.
const catalog = createMemo(() => {
const player = usePlayer()
if (!player) return
const playerInstance = player()
if (!playerInstance) return

const catalog = player.getCatalog()
const catalog = playerInstance.getCatalog()
return JSON.stringify(catalog, null, 2)
})

function updateURLWithTracknumber(trackIndex: number) {
const url = new URL(window.location.href)
url.searchParams.set("track", trackIndex.toString())
window.history.replaceState({}, "", decodeURIComponent(url.toString()))
}

createEffect(() => {
const player = usePlayer()
if (!player) return
const playerInstance = player()
if (!playerInstance) return

const videotracks = player.getVideoTracks()
setOptions(videotracks)

if (tracknum >= 0 && tracknum < videotracks.length) {
const selectedTrack = videotracks[tracknum]
setSelectedOption(selectedTrack)
updateURLWithTracknumber(tracknum)
}
onCleanup(() => playerInstance.close())
playerInstance.closed().then(setError).catch(setError)
})

const handleOptionSelectChange = (event: Event) => {
const selectedTrack = (event.target as HTMLSelectElement).value
setSelectedOption(selectedTrack)
void usePlayer()?.switchTrack(selectedTrack)

const videotracks = options()
const trackIndex = videotracks.indexOf(selectedTrack)
tracknum = trackIndex

if (trackIndex !== -1) {
updateURLWithTracknumber(trackIndex)
createEffect(() => {
if (hovered()) {
setShowControls(true)
return
}
}

const handleMuteChange = (event: Event) => {
const muteValue = (event.target as HTMLInputElement).checked

setMute(muteValue)
void usePlayer()?.mute(muteValue)
}
const timeoutId = setTimeout(() => setShowControls(false), 3000)
onCleanup(() => clearTimeout(timeoutId))
})

// NOTE: The canvas automatically has width/height set to the decoded video size.
// TODO shrink it if needed via CSS
return (
<>
<Fail error={error()} />
<canvas ref={canvas} onClick={play} class="aspect-video w-full rounded-lg" />
<div class="mt-4 flex flex-col space-y-4">
<div class="flex items-center space-x-4">
<select value={selectedOption() ?? ''} onChange={handleOptionSelectChange}>
{options()?.length ? (
options().map((option) => <option value={option}>{option}</option>)
) : (
<option disabled>No options available</option>
)}
</select>
<label class="flex items-center space-x-2">
<input type="checkbox" checked={mute()} onChange={handleMuteChange} />
<span>Mute</span>
</label>
<button onClick={handlePlayPause}>{"Play/Pause"}</button>
<div class="relative aspect-video w-full">
<canvas
ref={canvas}
onClick={handlePlayPause}
class="h-full w-full rounded-lg"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>
<div
class={`mr-px-4 ml-px-4 ${
showControls() ? "opacity-100" : "opacity-0"
} absolute bottom-4 flex h-[40px] w-[100%] items-center gap-[4px] rounded transition-opacity duration-200 `}
>
<PlayButton onClick={handlePlayPause} isPlaying={isPlaying()} />
<div class="absolute bottom-0 right-4 flex h-[32px] w-fit items-center justify-evenly gap-[4px] rounded bg-black/70 p-2">
<VolumeButton mute={mute} />
<TrackSelect trackNum={tracknum} getVideoTracks={getVideoTracks} switchTrack={switchTrack} />
</div>
</div>
</div>
<h3>Debug</h3>
Expand Down
Loading