diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 6237f341..ac218ae3 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -17,6 +17,7 @@ import { usePlayerStore } from "@/stores/player/store"; import { CaptionSettingsView } from "./settings/CaptionSettingsView"; import { CaptionsView } from "./settings/CaptionsView"; +import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { QualityView } from "./settings/QualityView"; function SettingsOverlay({ id }: { id: string }) { @@ -39,7 +40,7 @@ function SettingsOverlay({ id }: { id: string }) { - + @@ -64,6 +65,11 @@ function SettingsOverlay({ id }: { id: string }) { + + + + + ); diff --git a/src/components/player/atoms/VolumeChangedPopout.tsx b/src/components/player/atoms/VolumeChangedPopout.tsx new file mode 100644 index 00000000..a258bd34 --- /dev/null +++ b/src/components/player/atoms/VolumeChangedPopout.tsx @@ -0,0 +1,43 @@ +import { Icon, Icons } from "@/components/Icon"; +import { Transition } from "@/components/Transition"; +import { Flare } from "@/components/utils/Flare"; +import { usePlayerStore } from "@/stores/player/store"; +import { useEmpheralVolumeStore } from "@/stores/volume"; + +export function VolumeChangedPopout() { + const empheralVolume = useEmpheralVolumeStore(); + + const volume = usePlayerStore((s) => s.mediaPlaying.volume); + + return ( + + + + + 0 ? Icons.VOLUME : Icons.VOLUME_X} + /> +
+
+
+
+
+ + + + ); +} diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts index 2c71620c..53076cb3 100644 --- a/src/components/player/atoms/index.ts +++ b/src/components/player/atoms/index.ts @@ -11,3 +11,4 @@ export * from "./EpisodeTitle"; export * from "./Settings"; export * from "./Episodes"; export * from "./Airplay"; +export * from "./VolumeChangedPopout"; diff --git a/src/components/player/atoms/settings/CaptionSettingsView.tsx b/src/components/player/atoms/settings/CaptionSettingsView.tsx index 9a620492..3fcaa847 100644 --- a/src/components/player/atoms/settings/CaptionSettingsView.tsx +++ b/src/components/player/atoms/settings/CaptionSettingsView.tsx @@ -43,7 +43,6 @@ function CaptionSetting(props: { const inputRef = useRef(null); const ref = useRef(null); - // 200 - 100 150 - 100 const currentPercentage = (props.value - props.min) / (props.max - props.min); const commit = useCallback( (percentage) => { @@ -173,7 +172,7 @@ export function CaptionSettingsView({ id }: { id: string }) { `${s}%`} onChange={(v) => updateStyling({ size: v / 100 })} value={styling.size * 100} diff --git a/src/components/player/atoms/settings/PlaybackSettingsView.tsx b/src/components/player/atoms/settings/PlaybackSettingsView.tsx new file mode 100644 index 00000000..de6a95b1 --- /dev/null +++ b/src/components/player/atoms/settings/PlaybackSettingsView.tsx @@ -0,0 +1,67 @@ +import classNames from "classnames"; +import { useCallback } from "react"; + +import { Context } from "@/components/player/internals/ContextUtils"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { usePlayerStore } from "@/stores/player/store"; + +function ButtonList(props: { + options: number[]; + selected: number; + onClick: (v: any) => void; +}) { + return ( +
+ {props.options.map((option) => { + return ( + + ); + })} +
+ ); +} + +export function PlaybackSettingsView({ id }: { id: string }) { + const router = useOverlayRouter(id); + const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); + const display = usePlayerStore((s) => s.display); + + const setPlaybackRate = useCallback( + (v: number) => { + display?.setPlaybackRate(v); + }, + [display] + ); + + const options = [0.25, 0.5, 1, 1.25, 2]; + + return ( + <> + router.navigate("/")}> + Playback settings + + +
+ Playback speed + +
+
+ + ); +} diff --git a/src/components/player/atoms/settings/QualityView.tsx b/src/components/player/atoms/settings/QualityView.tsx index 139eb802..aa8d4d05 100644 --- a/src/components/player/atoms/settings/QualityView.tsx +++ b/src/components/player/atoms/settings/QualityView.tsx @@ -43,20 +43,29 @@ export function QualityView({ id }: { id: string }) { const availableQualities = usePlayerStore((s) => s.qualities); const currentQuality = usePlayerStore((s) => s.currentQuality); const switchQuality = usePlayerStore((s) => s.switchQuality); + const enableAutomaticQuality = usePlayerStore( + (s) => s.enableAutomaticQuality + ); const setAutomaticQuality = useQualityStore((s) => s.setAutomaticQuality); const setLastChosenQuality = useQualityStore((s) => s.setLastChosenQuality); const autoQuality = useQualityStore((s) => s.quality.automaticQuality); const change = useCallback( (q: SourceQuality) => { - switchQuality(q); setLastChosenQuality(q); setAutomaticQuality(false); + switchQuality(q); router.close(); }, [router, switchQuality, setLastChosenQuality, setAutomaticQuality] ); + const changeAutomatic = useCallback(() => { + const newValue = !autoQuality; + setAutomaticQuality(newValue); + if (newValue) enableAutomaticQuality(); + }, [setAutomaticQuality, autoQuality, enableAutomaticQuality]); + const allVisibleQualities = allQualities.filter((t) => t !== "unknown"); return ( @@ -80,10 +89,7 @@ export function QualityView({ id }: { id: string }) { Automatic quality - setAutomaticQuality(!autoQuality)} - enabled={autoQuality} - /> + You can try{" "} diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index f9d88e24..249cb9fd 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -69,7 +69,7 @@ export function SettingsMenu({ id }: { id: string }) { {selectedCaptionLanguage ?? ""} - + router.navigate("/playback")}> Playback settings diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx index 927be3f0..3e65643d 100644 --- a/src/components/player/base/Container.tsx +++ b/src/components/player/base/Container.tsx @@ -2,6 +2,7 @@ import { ReactNode, RefObject, useEffect, useRef } from "react"; import { OverlayDisplay } from "@/components/overlays/OverlayDisplay"; import { HeadUpdater } from "@/components/player/internals/HeadUpdater"; +import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents"; import { ProgressSaver } from "@/components/player/internals/ProgressSaver"; import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget"; import { VideoContainer } from "@/components/player/internals/VideoContainer"; @@ -82,6 +83,7 @@ export function Container(props: PlayerProps) { +
diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 303bda5d..1fe5f9c1 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -6,7 +6,11 @@ import { DisplayInterfaceEvents, } from "@/components/player/display/displayInterface"; import { handleBuffered } from "@/components/player/utils/handleBuffered"; -import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities"; +import { + LoadableSource, + SourceQuality, + getPreferredQuality, +} from "@/stores/player/utils/qualities"; import { canChangeVolume, canFullscreen, @@ -26,6 +30,18 @@ function hlsLevelToQuality(level: Level): SourceQuality | null { return levelConversionMap[level.height] ?? null; } +function qualityToHlsLevel(quality: SourceQuality): number | null { + const found = Object.entries(levelConversionMap).find( + (entry) => entry[1] === quality + ); + return found ? +found[0] : null; +} +function hlsLevelsToQualities(levels: Level[]): SourceQuality[] { + return levels + .map((v) => hlsLevelToQuality(v)) + .filter((v): v is SourceQuality => !!v); +} + export function makeVideoElementDisplayInterface(): DisplayInterface { const { emit, on, off } = makeEmitter(); let source: LoadableSource | null = null; @@ -36,6 +52,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { let isPausedBeforeSeeking = false; let isSeeking = false; let startAt = 0; + let automaticQuality = false; + let preferenceQuality: SourceQuality | null = null; function reportLevels() { if (!hls) return; @@ -46,6 +64,34 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { emit("qualities", convertedLevels); } + function setupQualityForHls() { + if (!hls) return; + if (!automaticQuality) { + const qualities = hlsLevelsToQualities(hls.levels); + const availableQuality = getPreferredQuality(qualities, { + lastChosenQuality: preferenceQuality, + automaticQuality, + }); + if (availableQuality) { + const levelIndex = hls.levels.findIndex( + (v) => v.height === qualityToHlsLevel(availableQuality) + ); + if (levelIndex !== -1) { + console.log("setting level", levelIndex, availableQuality); + hls.currentLevel = levelIndex; + hls.loadLevel = levelIndex; + } + } + } else { + console.log("setting to automatic"); + hls.currentLevel = -1; + hls.loadLevel = -1; + } + const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); + console.log("updating quality menu", quality); + emit("changedquality", quality); + } + function setupSource(vid: HTMLVideoElement, src: LoadableSource) { if (src.type === "hls") { if (!Hls.isSupported()) throw new Error("HLS not supported"); @@ -63,12 +109,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { hls.on(Hls.Events.MANIFEST_LOADED, () => { if (!hls) return; reportLevels(); - const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); - emit("changedquality", quality); + setupQualityForHls(); }); hls.on(Hls.Events.LEVEL_SWITCHED, () => { if (!hls) return; const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); + console.log("EVENT updating quality menu", quality); emit("changedquality", quality); }); } @@ -124,6 +170,9 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { } } ); + videoElement.addEventListener("ratechange", () => { + if (videoElement) emit("playbackrate", videoElement.playbackRate); + }); } function unloadSource() { @@ -157,13 +206,21 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { destroyVideoElement(); fscreen.removeEventListener("fullscreenchange", fullscreenChange); }, - load(newSource, startAtInput) { - if (!newSource) unloadSource(); - source = newSource; + load(ops) { + if (!ops.source) unloadSource(); + automaticQuality = ops.automaticQuality; + preferenceQuality = ops.preferredQuality; + source = ops.source; emit("loading", true); - startAt = startAtInput; + startAt = ops.startAt; setSource(); }, + changeQuality(newAutomaticQuality, newPreferredQuality) { + if (source?.type !== "hls") return; + automaticQuality = newAutomaticQuality; + preferenceQuality = newPreferredQuality; + setupQualityForHls(); + }, processVideoElement(video) { destroyVideoElement(); @@ -251,5 +308,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { videoPlayer.webkitShowPlaybackTargetPicker(); } }, + setPlaybackRate(rate) { + if (videoElement) videoElement.playbackRate = rate; + }, }; } diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index a54207b6..a7a9d4b7 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -14,12 +14,24 @@ export type DisplayInterfaceEvents = { changedquality: SourceQuality | null; needstrack: boolean; canairplay: boolean; + playbackrate: number; }; +export interface qualityChangeOptions { + source: LoadableSource | null; + automaticQuality: boolean; + preferredQuality: SourceQuality | null; + startAt: number; +} + export interface DisplayInterface extends Listener { play(): void; pause(): void; - load(source: LoadableSource | null, startAt: number): void; + load(ops: qualityChangeOptions): void; + changeQuality( + automaticQuality: boolean, + preferredQuality: SourceQuality | null + ): void; processVideoElement(video: HTMLVideoElement): void; processContainerElement(container: HTMLElement): void; toggleFullscreen(): void; @@ -28,4 +40,5 @@ export interface DisplayInterface extends Listener { setTime(t: number): void; destroy(): void; startAirplay(): void; + setPlaybackRate(rate: number): void; } diff --git a/src/components/player/internals/ContextUtils.tsx b/src/components/player/internals/ContextUtils.tsx index 5a2e00f4..6e8c2025 100644 --- a/src/components/player/internals/ContextUtils.tsx +++ b/src/components/player/internals/ContextUtils.tsx @@ -135,7 +135,7 @@ function IconButton(props: { icon: Icons; onClick?: () => void }) { } function Divider() { - return
; + return
; } function SmallText(props: { children: React.ReactNode }) { diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx new file mode 100644 index 00000000..ed29ce60 --- /dev/null +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from "react"; + +import { useVolume } from "@/components/player/hooks/useVolume"; +import { usePlayerStore } from "@/stores/player/store"; +import { useEmpheralVolumeStore } from "@/stores/volume"; + +export function KeyboardEvents() { + const display = usePlayerStore((s) => s.display); + const mediaPlaying = usePlayerStore((s) => s.mediaPlaying); + const time = usePlayerStore((s) => s.progress.time); + const { setVolume, toggleMute } = useVolume(); + + const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); + + const [isRolling, setIsRolling] = useState(false); + const volumeDebounce = useRef | undefined>(); + + const dataRef = useRef({ + setShowVolume, + setVolume, + toggleMute, + setIsRolling, + display, + mediaPlaying, + isRolling, + time, + }); + useEffect(() => { + dataRef.current = { + setShowVolume, + setVolume, + toggleMute, + setIsRolling, + display, + mediaPlaying, + isRolling, + time, + }; + }, [ + setShowVolume, + setVolume, + toggleMute, + setIsRolling, + display, + mediaPlaying, + isRolling, + time, + ]); + + useEffect(() => { + const keyEventHandler = (evt: KeyboardEvent) => { + const k = evt.key; + + // Volume + if (["ArrowUp", "ArrowDown", "m"].includes(k)) { + dataRef.current.setShowVolume(true); + + if (volumeDebounce.current) clearTimeout(volumeDebounce.current); + volumeDebounce.current = setTimeout(() => { + dataRef.current.setShowVolume(false); + }, 3e3); + } + if (k === "ArrowUp") + dataRef.current.setVolume( + (dataRef.current.mediaPlaying?.volume || 0) + 0.15 + ); + if (k === "ArrowDown") + dataRef.current.setVolume( + (dataRef.current.mediaPlaying?.volume || 0) - 0.15 + ); + if (k === "m") dataRef.current.toggleMute(); + + // Video progress + if (k === "ArrowRight") + dataRef.current.display?.setTime(dataRef.current.time + 5); + if (k === "ArrowLeft") + dataRef.current.display?.setTime(dataRef.current.time - 5); + + // Utils + if (k === "f") dataRef.current.display?.toggleFullscreen(); + if (k === " ") + dataRef.current.display?.[ + dataRef.current.mediaPlaying.isPaused ? "play" : "pause" + ](); + + // Do a barrell roll! + if (k === "r") { + if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return; + + dataRef.current.setIsRolling(true); + document.querySelector(".popout-location")?.classList.add("roll"); + document.body.setAttribute("data-no-scroll", "true"); + + setTimeout(() => { + document.querySelector(".popout-location")?.classList.remove("roll"); + document.body.removeAttribute("data-no-scroll"); + dataRef.current.setIsRolling(false); + }, 1e3); + } + }; + window.addEventListener("keydown", keyEventHandler); + + return () => { + window.removeEventListener("keydown", keyEventHandler); + }; + }, []); + + return null; +} diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 3d6973f4..5f18c2c7 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; import { BrandPill } from "@/components/layout/BrandPill"; import { Player } from "@/components/player"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; -import { PlayerMeta } from "@/stores/player/slices/source"; +import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; export interface PlayerPartProps { @@ -23,7 +23,7 @@ export function PlayerPart(props: PlayerPartProps) { - {status === "playing" ? ( + {status === playerStatus.PLAYING ? ( @@ -78,6 +78,8 @@ export function PlayerPart(props: PlayerPartProps) {
+ + ); } diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts index 1fbe7efc..e777f607 100644 --- a/src/stores/player/slices/display.ts +++ b/src/stores/player/slices/display.ts @@ -85,13 +85,23 @@ export const createDisplaySlice: MakeSlice = (set, get) => ({ s.interface.canAirplay = canAirplay; }); }); + newDisplay.on("playbackrate", (rate) => { + set((s) => { + s.mediaPlaying.playbackRate = rate; + }); + }); set((s) => { s.display = newDisplay; }); }, reset() { - get().display?.load(null, 0); + get().display?.load({ + source: null, + startAt: 0, + automaticQuality: false, + preferredQuality: null, + }); set((s) => { s.status = playerStatus.IDLE; s.meta = null; diff --git a/src/stores/player/slices/playing.ts b/src/stores/player/slices/playing.ts index 24c91b1d..268cd873 100644 --- a/src/stores/player/slices/playing.ts +++ b/src/stores/player/slices/playing.ts @@ -9,7 +9,7 @@ export interface PlayingSlice { isLoading: boolean; // buffering or not hasPlayedOnce: boolean; // has the video played at all? volume: number; - playbackSpeed: number; + playbackRate: number; }; play(): void; pause(): void; @@ -22,10 +22,9 @@ export const createPlayingSlice: MakeSlice = (set) => ({ isLoading: false, isSeeking: false, isDragSeeking: false, - isFirstLoading: true, hasPlayedOnce: false, volume: 1, - playbackSpeed: 1, + playbackRate: 1, }, play() { set((state) => { diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index ea7b89d9..ddba0d8e 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -59,6 +59,7 @@ export interface SourceSlice { setMeta(meta: PlayerMeta): void; setCaption(caption: Caption | null): void; setSourceId(id: string | null): void; + enableAutomaticQuality(): void; } export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { @@ -128,7 +129,12 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ s.currentQuality = loadableStream.quality; }); - store.display?.load(loadableStream.stream, startAt); + store.display?.load({ + source: loadableStream.stream, + startAt, + automaticQuality: qualityPreferences.quality.automaticQuality, + preferredQuality: qualityPreferences.quality.lastChosenQuality, + }); }, switchQuality(quality) { const store = get(); @@ -139,7 +145,18 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ set((s) => { s.currentQuality = quality; }); - store.display?.load(selectedQuality, store.progress.time); + store.display?.load({ + source: selectedQuality, + startAt: store.progress.time, + automaticQuality: false, + preferredQuality: quality, + }); + } else if (store.source.type === "hls") { + store.display?.changeQuality(false, quality); } }, + enableAutomaticQuality() { + const store = get(); + store.display?.changeQuality(true, null); + }, }); diff --git a/src/stores/player/utils/qualities.ts b/src/stores/player/utils/qualities.ts index 4082a9a2..66ebcf52 100644 --- a/src/stores/player/utils/qualities.ts +++ b/src/stores/player/utils/qualities.ts @@ -35,7 +35,7 @@ const sortedQualities: SourceQuality[] = Object.entries(qualitySorting) .sort((a, b) => b[1] - a[1]) .map((v) => v[0] as SourceQuality); -function getPreferredQuality( +export function getPreferredQuality( availableQualites: SourceQuality[], qualityPreferences: QualityStore["quality"] ) { diff --git a/src/stores/subtitles/index.ts b/src/stores/subtitles/index.ts index 40787e42..079799af 100644 --- a/src/stores/subtitles/index.ts +++ b/src/stores/subtitles/index.ts @@ -9,7 +9,7 @@ export interface SubtitleStyling { color: string; /** - * size percentage, ranges between 0 and 2 + * size percentage, ranges between 0.01 and 2 */ size: number; @@ -45,7 +45,7 @@ export const useSubtitleStore = create( if (newStyling.color !== undefined) s.styling.color = newStyling.color.toLowerCase(); if (newStyling.size !== undefined) - s.styling.size = Math.min(2, Math.max(0.1, newStyling.size)); + s.styling.size = Math.min(2, Math.max(0.01, newStyling.size)); }); }, setLanguage(lang) { diff --git a/src/stores/volume/index.ts b/src/stores/volume/index.ts index 05296b5d..fe753718 100644 --- a/src/stores/volume/index.ts +++ b/src/stores/volume/index.ts @@ -7,6 +7,11 @@ export interface VolumeStore { setVolume(v: number): void; } +export interface EmpheralVolumeStore { + showVolume: boolean; + setShowVolume(v: boolean): void; +} + // TODO add migration from previous stored volume export const useVolumeStore = create( persist( @@ -23,3 +28,14 @@ export const useVolumeStore = create( } ) ); + +export const useEmpheralVolumeStore = create( + immer((set) => ({ + showVolume: false, + setShowVolume(bool: boolean) { + set((s) => { + s.showVolume = bool; + }); + }, + })) +); diff --git a/tailwind.config.js b/tailwind.config.js index b5a0e701..b9045b08 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -142,6 +142,11 @@ module.exports = { slider: "#8787A8", sliderFilled: "#A75FC9", + buttons: { + list: "#161C26", + active: "#0D1317" + }, + type: { main: "#617A8A", secondary: "#374A56",