pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

thumbnail fixes + next episode fixes + cursor now hides when controls are dismissed + back link can go back to search + hovering over controls no longer dismisses controls + improved colors for context menus + progress ring shown in episode selector + scrape progress ring shows progress again

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>

mrjvs 068b7071 46cb7793

+234 -59
+35 -3
src/components/player/atoms/Episodes.tsx
··· 1 + import classNames from "classnames"; 1 2 import { ReactNode, useCallback, useEffect, useState } from "react"; 2 3 import { useTranslation } from "react-i18next"; 3 4 import { useAsync } from "react-use"; ··· 5 6 import { getMetaFromId } from "@/backend/metadata/getmeta"; 6 7 import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw"; 7 8 import { Icons } from "@/components/Icon"; 9 + import { ProgressRing } from "@/components/layout/ProgressRing"; 8 10 import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; 9 11 import { Overlay } from "@/components/overlays/OverlayDisplay"; 10 12 import { OverlayPage } from "@/components/overlays/OverlayPage"; ··· 15 17 import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 16 18 import { PlayerMeta } from "@/stores/player/slices/source"; 17 19 import { usePlayerStore } from "@/stores/player/store"; 20 + import { useProgressStore } from "@/stores/progress"; 18 21 19 22 function CenteredText(props: { children: React.ReactNode }) { 20 23 return ( ··· 98 101 const { setPlayerMeta } = usePlayerMeta(); 99 102 const meta = usePlayerStore((s) => s.meta); 100 103 const [loadingState] = useSeasonData(meta?.tmdbId ?? "", selectedSeason); 104 + const progress = useProgressStore(); 101 105 102 106 const playEpisode = useCallback( 103 107 (episodeId: string) => { ··· 109 113 }, 110 114 [setPlayerMeta, loadingState, router, onChange] 111 115 ); 116 + 117 + if (!meta?.tmdbId) return null; 112 118 113 119 let content: ReactNode = null; 114 120 if (loadingState.error) ··· 124 130 </Menu.TextDisplay> 125 131 ) : null} 126 132 {loadingState.value.season.episodes.map((ep) => { 133 + const episodeProgress = 134 + progress.items[meta?.tmdbId]?.episodes?.[ep.id]; 135 + 136 + let rightSide; 137 + if (episodeProgress) { 138 + const percentage = 139 + (episodeProgress.progress.watched / 140 + episodeProgress.progress.duration) * 141 + 100; 142 + rightSide = ( 143 + <ProgressRing 144 + className="h-[18px] w-[18px] text-white" 145 + percentage={percentage > 90 ? 100 : percentage} 146 + /> 147 + ); 148 + } 149 + 127 150 return ( 128 - <Menu.ChevronLink 151 + <Menu.Link 129 152 key={ep.id} 130 153 onClick={() => playEpisode(ep.id)} 131 154 active={ep.id === meta?.episode?.tmdbId} 155 + clickable 156 + rightSide={rightSide} 132 157 > 133 158 <Menu.LinkTitle> 134 159 <div className="text-left flex items-center space-x-3"> 135 - <span className="p-0.5 px-2 rounded inline bg-video-context-border"> 160 + <span 161 + className={classNames( 162 + "p-0.5 px-2 rounded inline bg-video-context-hoverColor", 163 + ep.id === meta?.episode?.tmdbId 164 + ? "text-white bg-opacity-100" 165 + : "bg-opacity-50" 166 + )} 167 + > 136 168 E{ep.number} 137 169 </span> 138 170 <span className="line-clamp-1 break-all">{ep.title}</span> 139 171 </div> 140 172 </Menu.LinkTitle> 141 - </Menu.ChevronLink> 173 + </Menu.Link> 142 174 ); 143 175 })} 144 176 </Menu.Section>
+7 -3
src/components/player/atoms/NextEpisodeButton.tsx
··· 4 4 import { Icon, Icons } from "@/components/Icon"; 5 5 import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; 6 6 import { Transition } from "@/components/Transition"; 7 - import { PlayerMetaEpisode } from "@/stores/player/slices/source"; 7 + import { PlayerMeta } from "@/stores/player/slices/source"; 8 8 import { usePlayerStore } from "@/stores/player/store"; 9 9 10 10 function shouldShowNextEpisodeButton( ··· 37 37 ); 38 38 } 39 39 40 - export function NextEpisodeButton(props: { controlsShowing: boolean }) { 40 + export function NextEpisodeButton(props: { 41 + controlsShowing: boolean; 42 + onChange?: (meta: PlayerMeta) => void; 43 + }) { 41 44 const duration = usePlayerStore((s) => s.progress.duration); 42 45 const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn); 43 46 const meta = usePlayerStore((s) => s.meta); ··· 67 70 const metaCopy = { ...meta }; 68 71 metaCopy.episode = nextEp; 69 72 setDirectMeta(metaCopy); 70 - }, [setDirectMeta, nextEp, meta]); 73 + props.onChange?.(metaCopy); 74 + }, [setDirectMeta, nextEp, meta, props]); 71 75 72 76 if (!meta?.episode || !nextEp) return null; 73 77 if (metaType !== "show") return null;
+20 -5
src/components/player/base/BottomControls.tsx
··· 1 + import { useEffect } from "react"; 2 + 1 3 import { Transition } from "@/components/Transition"; 4 + import { usePlayerStore } from "@/stores/player/store"; 2 5 3 6 export function BottomControls(props: { 4 7 show?: boolean; 5 8 children: React.ReactNode; 6 9 }) { 10 + const setHoveringAnyControls = usePlayerStore( 11 + (s) => s.setHoveringAnyControls 12 + ); 13 + 14 + useEffect(() => { 15 + return () => { 16 + setHoveringAnyControls(false); 17 + }; 18 + }, [setHoveringAnyControls]); 19 + 7 20 return ( 8 21 <div className="w-full text-white"> 9 22 <Transition ··· 11 24 show={props.show} 12 25 className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full" 13 26 /> 14 - <Transition 15 - animation="slide-up" 16 - show={props.show} 27 + <div 28 + onMouseOver={() => setHoveringAnyControls(true)} 29 + onMouseOut={() => setHoveringAnyControls(false)} 17 30 className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pb-3 mb-[env(safe-area-inset-bottom)] absolute bottom-0 w-full" 18 31 > 19 - {props.children} 20 - </Transition> 32 + <Transition animation="slide-up" show={props.show}> 33 + {props.children} 34 + </Transition> 35 + </div> 21 36 </div> 22 37 ); 23 38 }
+2 -1
src/components/player/base/Container.tsx
··· 13 13 14 14 export interface PlayerProps { 15 15 children?: ReactNode; 16 + showingControls: boolean; 16 17 onLoad?: () => void; 17 18 } 18 19 ··· 89 90 <ProgressSaver /> 90 91 <KeyboardEvents /> 91 92 <div className="relative h-screen overflow-hidden"> 92 - <VideoClickTarget /> 93 + <VideoClickTarget showingControls={props.showingControls} /> 93 94 <HeadUpdater /> 94 95 {props.children} 95 96 </div>
+25 -6
src/components/player/base/TopControls.tsx
··· 1 + import { useEffect } from "react"; 2 + 1 3 import { Transition } from "@/components/Transition"; 4 + import { usePlayerStore } from "@/stores/player/store"; 2 5 3 6 export function TopControls(props: { 4 7 show?: boolean; 5 8 children: React.ReactNode; 6 9 }) { 10 + const setHoveringAnyControls = usePlayerStore( 11 + (s) => s.setHoveringAnyControls 12 + ); 13 + 14 + useEffect(() => { 15 + return () => { 16 + setHoveringAnyControls(false); 17 + }; 18 + }, [setHoveringAnyControls]); 19 + 7 20 return ( 8 21 <div className="w-full text-white"> 9 22 <Transition ··· 11 24 show={props.show} 12 25 className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full" 13 26 /> 14 - <Transition 15 - animation="slide-down" 16 - show={props.show} 17 - className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pt-6 absolute top-0 w-full text-white" 27 + <div 28 + onMouseOver={() => setHoveringAnyControls(true)} 29 + onMouseOut={() => setHoveringAnyControls(false)} 30 + className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pt-6 absolute top-0 w-full" 18 31 > 19 - {props.children} 20 - </Transition> 32 + <Transition 33 + animation="slide-down" 34 + show={props.show} 35 + className="text-white" 36 + > 37 + {props.children} 38 + </Transition> 39 + </div> 21 40 </div> 22 41 ); 23 42 }
+6 -2
src/components/player/display/base.ts
··· 54 54 let startAt = 0; 55 55 let automaticQuality = false; 56 56 let preferenceQuality: SourceQuality | null = null; 57 + let lastVolume = 1; 57 58 58 59 function reportLevels() { 59 60 if (!hls) return; ··· 226 227 destroyVideoElement(); 227 228 videoElement = video; 228 229 setSource(); 230 + this.setVolume(lastVolume); 229 231 }, 230 232 processContainerElement(container) { 231 233 containerElement = container; ··· 261 263 videoElement.currentTime = time; 262 264 }, 263 265 async setVolume(v) { 264 - if (!videoElement) return; 265 - 266 266 // clamp time between 0 and 1 267 267 let volume = Math.min(v, 1); 268 268 volume = Math.max(0, volume); 269 + 270 + // actually set 271 + lastVolume = v; 272 + if (!videoElement) return; 269 273 videoElement.muted = volume === 0; // Muted attribute is always supported 270 274 271 275 // update state
+1 -1
src/components/player/hooks/usePlayerMeta.ts
··· 16 16 const setDirectMeta = useCallback( 17 17 (m: PlayerMeta) => { 18 18 _setPlayerMeta(m); 19 - setMeta(m); 20 19 setScrapeStatus(); 20 + setMeta(m); 21 21 }, 22 22 [_setPlayerMeta, setMeta, setScrapeStatus] 23 23 );
+5 -1
src/components/player/hooks/useShouldShowControls.tsx
··· 8 8 ); 9 9 const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); 10 10 const hasOpenOverlay = usePlayerStore((s) => s.interface.hasOpenOverlay); 11 + const isHoveringControls = usePlayerStore( 12 + (s) => s.interface.isHoveringControls 13 + ); 11 14 12 15 const isUsingTouch = lastHoveringState === PlayerHoverState.MOBILE_TAPPED; 13 16 const isHovering = hovering !== PlayerHoverState.NOT_HOVERING; 14 17 15 18 // when using touch, pause screens can be dismissed by tapping 16 - const showTargetsWithoutPause = isHovering || hasOpenOverlay; 19 + const showTargetsWithoutPause = 20 + isHovering || isHoveringControls || hasOpenOverlay; 17 21 const showTargetsIncludingPause = showTargetsWithoutPause || isPaused; 18 22 const showTargets = isUsingTouch 19 23 ? showTargetsWithoutPause
+3 -2
src/components/player/internals/ContextMenu/Links.tsx
··· 58 58 }) { 59 59 const classes = classNames("flex py-2 px-3 rounded w-full -ml-3", { 60 60 "cursor-default": !props.clickable, 61 - "hover:bg-video-context-border cursor-pointer": props.clickable, 62 - "bg-video-context-border": props.active, 61 + "hover:bg-video-context-hoverColor hover:bg-opacity-50 cursor-pointer": 62 + props.clickable, 63 + "bg-video-context-hoverColor bg-opacity-50": props.active, 63 64 }); 64 65 const styles = { width: "calc(100% + 1.5rem)" }; 65 66
+10 -10
src/components/player/internals/StatusCircle.tsx
··· 43 43 props.type === "noresult", 44 44 })} 45 45 > 46 - <svg 47 - width="100%" 48 - height="100%" 49 - viewBox="0 0 64 64" 50 - xmlns="http://www.w3.org/2000/svg" 51 - className="rounded-full -rotate-90" 52 - > 53 - <Transition animation="fade" show={statusIsLoading(props)}> 46 + <Transition animation="fade" show={statusIsLoading(props)}> 47 + <svg 48 + width="100%" 49 + height="100%" 50 + viewBox="0 0 64 64" 51 + xmlns="http://www.w3.org/2000/svg" 52 + className="rounded-full -rotate-90" 53 + > 54 54 <a.circle 55 55 strokeWidth="32" 56 56 strokeDasharray={to(spring.percentage, (val) => `${val} 100`)} ··· 61 61 stroke="currentColor" 62 62 className="transition-[strokeDasharray]" 63 63 /> 64 - </Transition> 65 - </svg> 64 + </svg> 65 + </Transition> 66 66 <Transition animation="fade" show={props.type === "error"}> 67 67 <Icon 68 68 className="absolute inset-0 flex items-center justify-center text-white"
+31 -15
src/components/player/internals/ThumbnailScraper.tsx
··· 1 1 import Hls from "hls.js"; 2 - import { useEffect, useMemo, useRef } from "react"; 2 + import { useCallback, useEffect, useRef } from "react"; 3 3 4 + import { playerStatus } from "@/stores/player/slices/source"; 4 5 import { ThumbnailImage } from "@/stores/player/slices/thumbnails"; 5 6 import { usePlayerStore } from "@/stores/player/store"; 6 7 import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities"; ··· 54 55 } 55 56 56 57 destroy() { 57 - this.hls?.detachMedia(); 58 - this.hls?.destroy(); 59 - this.hls = null; 60 58 this.interrupted = true; 61 59 this.videoEl = null; 62 60 this.canvasEl = null; 61 + this.hls?.detachMedia(); 62 + this.hls?.destroy(); 63 + this.hls = null; 63 64 } 64 65 65 66 private async initVideo() { ··· 91 92 ); 92 93 const imgUrl = this.canvasEl.toDataURL(); 93 94 if (this.interrupted) return; 95 + 94 96 this.cb({ 95 97 at, 96 98 data: imgUrl, ··· 112 114 113 115 export function ThumbnailScraper() { 114 116 const addImage = usePlayerStore((s) => s.thumbnails.addImage); 117 + const status = usePlayerStore((s) => s.status); 118 + const resetImages = usePlayerStore((s) => s.thumbnails.resetImages); 115 119 const meta = usePlayerStore((s) => s.meta); 116 120 const source = usePlayerStore((s) => s.source); 117 121 const workerRef = useRef<ThumnbnailWorker | null>(null); 118 122 119 - const inputStream = useMemo(() => { 120 - if (!source) return null; 121 - return selectQuality(source, { 122 - automaticQuality: false, 123 - lastChosenQuality: "360", 124 - }); 125 - }, [source]); 123 + // object references dont always trigger changes, so we serialize it to detect *any* change 124 + const sourceSeralized = JSON.stringify(source); 126 125 127 - // start worker with the stream 128 - useEffect(() => { 126 + const start = useCallback(() => { 127 + let inputStream = null; 128 + if (source) 129 + inputStream = selectQuality(source, { 130 + automaticQuality: false, 131 + lastChosenQuality: "360", 132 + }); 129 133 // dont interrupt existing working 130 134 if (workerRef.current) return; 135 + if (status !== playerStatus.PLAYING) return; 131 136 if (!inputStream) return; 137 + resetImages(); 132 138 const ins = new ThumnbnailWorker({ 133 139 addImage, 134 140 }); 135 141 workerRef.current = ins; 136 142 ins.start(inputStream.stream); 137 - }, [inputStream, addImage]); 143 + }, [source, addImage, resetImages, status]); 144 + const startRef = useRef(start); 145 + useEffect(() => { 146 + startRef.current = start; 147 + }, [start, status]); 148 + 149 + // start worker with the stream 150 + useEffect(() => { 151 + startRef.current(); 152 + }, [sourceSeralized]); 138 153 139 154 // destroy worker on unmount 140 155 useEffect(() => { ··· 157 172 workerRef.current.destroy(); 158 173 workerRef.current = null; 159 174 } 160 - }, [serializedMeta]); 175 + startRef.current(); 176 + }, [serializedMeta, sourceSeralized, status]); 161 177 162 178 return null; 163 179 }
+6 -2
src/components/player/internals/VideoClickTarget.tsx
··· 1 + import classNames from "classnames"; 1 2 import { PointerEvent, useCallback } from "react"; 2 3 3 4 import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer"; 4 5 import { PlayerHoverState } from "@/stores/player/slices/interface"; 5 6 import { usePlayerStore } from "@/stores/player/store"; 6 7 7 - export function VideoClickTarget() { 8 + export function VideoClickTarget(props: { showingControls: boolean }) { 8 9 const show = useShouldShowVideoElement(); 9 10 const display = usePlayerStore((s) => s.display); 10 11 const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); ··· 41 42 42 43 return ( 43 44 <div 44 - className="absolute inset-0" 45 + className={classNames("absolute inset-0", { 46 + "absolute inset-0": true, 47 + "cursor-none": !props.showingControls, 48 + })} 45 49 onDoubleClick={toggleFullscreen} 46 50 onPointerUp={togglePause} 47 51 />
+3 -2
src/pages/PlayerView.tsx
··· 1 1 import { RunOutput } from "@movie-web/providers"; 2 - import { useCallback, useEffect, useState } from "react"; 2 + import { useCallback, useEffect } from "react"; 3 3 import { useHistory, useParams } from "react-router-dom"; 4 4 5 5 import { usePlayer } from "@/components/player/hooks/usePlayer"; ··· 8 8 import { MetaPart } from "@/pages/parts/player/MetaPart"; 9 9 import { PlayerPart } from "@/pages/parts/player/PlayerPart"; 10 10 import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; 11 + import { useLastNonPlayerLink } from "@/stores/history"; 11 12 import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; 12 13 13 14 export function PlayerView() { ··· 19 20 }>(); 20 21 const { status, playMedia, reset } = usePlayer(); 21 22 const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); 22 - const [backUrl] = useState("/"); // TODO redirect to search when needed 23 + const backUrl = useLastNonPlayerLink(); 23 24 24 25 const paramsData = JSON.stringify({ 25 26 media: params.media,
+5 -2
src/pages/parts/player/PlayerPart.tsx
··· 20 20 const { isMobile } = useIsMobile(); 21 21 22 22 return ( 23 - <Player.Container onLoad={props.onLoad}> 23 + <Player.Container onLoad={props.onLoad} showingControls={showTargets}> 24 24 {props.children} 25 25 <Player.BlackOverlay show={showTargets} /> 26 26 <Player.EpisodesRouter onChange={props.onMetaChange} /> ··· 98 98 </Player.BottomControls> 99 99 100 100 <Player.VolumeChangedPopout /> 101 - <Player.NextEpisodeButton controlsShowing={showTargets} /> 101 + <Player.NextEpisodeButton 102 + controlsShowing={showTargets} 103 + onChange={props.onMetaChange} 104 + /> 102 105 </Player.Container> 103 106 ); 104 107 }
+3 -3
src/pages/parts/player/ScrapingPart.tsx
··· 41 41 const currentProvider = sourceOrder.find( 42 42 (s) => sources[s.id].status === "pending" 43 43 ); 44 - const currentProviderIndex = sourceOrder.findIndex( 45 - (provider) => currentProvider?.id === provider.id 46 - ); 44 + const currentProviderIndex = 45 + sourceOrder.findIndex((provider) => currentProvider?.id === provider.id) ?? 46 + sourceOrder.length - 1; 47 47 48 48 return ( 49 49 <div className="h-full w-full relative" ref={containerRef}>
+3
src/setup/App.tsx
··· 20 20 import { BookmarkContextProvider } from "@/state/bookmark"; 21 21 import { SettingsProvider } from "@/state/settings"; 22 22 import { WatchedContextProvider } from "@/state/watched"; 23 + import { useHistoryListener } from "@/stores/history"; 23 24 24 25 function LegacyUrlView({ children }: { children: ReactElement }) { 25 26 const location = useLocation(); ··· 55 56 } 56 57 57 58 function App() { 59 + useHistoryListener(); 60 + 58 61 return ( 59 62 <SettingsProvider> 60 63 <WatchedContextProvider>
+53
src/stores/history/index.ts
··· 1 + import { useEffect, useMemo } from "react"; 2 + import { useHistory, useLocation } from "react-router-dom"; 3 + import { useEffectOnce } from "react-use"; 4 + import { create } from "zustand"; 5 + import { immer } from "zustand/middleware/immer"; 6 + 7 + interface HistoryRoute { 8 + path: string; 9 + } 10 + 11 + interface HistoryStore { 12 + routes: HistoryRoute[]; 13 + registerRoute(route: HistoryRoute): void; 14 + } 15 + 16 + export const useHistoryStore = create( 17 + immer<HistoryStore>((set) => ({ 18 + routes: [], 19 + registerRoute(route) { 20 + set((s) => { 21 + s.routes.push(route); 22 + }); 23 + }, 24 + })) 25 + ); 26 + 27 + export function useHistoryListener() { 28 + const history = useHistory(); 29 + const loc = useLocation(); 30 + const registerRoute = useHistoryStore((s) => s.registerRoute); 31 + useEffect( 32 + () => 33 + history.listen((a) => { 34 + registerRoute({ path: a.pathname }); 35 + }), 36 + [history, registerRoute] 37 + ); 38 + 39 + useEffectOnce(() => { 40 + registerRoute({ path: loc.pathname }); 41 + }); 42 + } 43 + 44 + export function useLastNonPlayerLink() { 45 + const routes = useHistoryStore((s) => s.routes); 46 + const lastNonPlayerLink = useMemo(() => { 47 + const reversedRoutes = [...routes]; 48 + reversedRoutes.reverse(); 49 + const route = reversedRoutes.find((v) => !v.path.startsWith("/media")); 50 + return route?.path ?? "/"; 51 + }, [routes]); 52 + return lastNonPlayerLink; 53 + }
+8
src/stores/player/slices/interface.ts
··· 27 27 volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig" 28 28 29 29 leftControlHovering: boolean; // is the cursor hovered over the left side of player controls 30 + isHoveringControls: boolean; // is the cursor hovered over any controls? 30 31 timeFormat: VideoPlayerTimeFormat; // Time format of the video player 31 32 }; 32 33 updateInterfaceHovering(newState: PlayerHoverState): void; 33 34 setSeeking(seeking: boolean): void; 34 35 setTimeFormat(format: VideoPlayerTimeFormat): void; 35 36 setHoveringLeftControls(state: boolean): void; 37 + setHoveringAnyControls(state: boolean): void; 36 38 setHasOpenOverlay(state: boolean): void; 37 39 setLastVolume(state: number): void; 38 40 hideNextEpisodeButton(): void; ··· 46 48 isSeeking: false, 47 49 lastVolume: 0, 48 50 leftControlHovering: false, 51 + isHoveringControls: false, 49 52 hovering: PlayerHoverState.NOT_HOVERING, 50 53 lastHoveringState: PlayerHoverState.NOT_HOVERING, 51 54 volumeChangedWithKeybind: false, ··· 87 90 setHoveringLeftControls(state) { 88 91 set((s) => { 89 92 s.interface.leftControlHovering = state; 93 + }); 94 + }, 95 + setHoveringAnyControls(state) { 96 + set((s) => { 97 + s.interface.isHoveringControls = state; 90 98 }); 91 99 }, 92 100 hideNextEpisodeButton() {
+6
src/stores/player/slices/thumbnails.ts
··· 9 9 thumbnails: { 10 10 images: ThumbnailImage[]; 11 11 addImage(img: ThumbnailImage): void; 12 + resetImages(): void; 12 13 }; 13 14 } 14 15 ··· 73 74 export const createThumbnailSlice: MakeSlice<ThumbnailSlice> = (set, get) => ({ 74 75 thumbnails: { 75 76 images: [], 77 + resetImages() { 78 + set((s) => { 79 + s.thumbnails.images = []; 80 + }); 81 + }, 76 82 addImage(img) { 77 83 const store = get(); 78 84 const exactOrPastImageIndex = store.thumbnails.images.findIndex(
+2 -1
tailwind.config.js
··· 143 143 context: { 144 144 background: "#0C1216", 145 145 light: "#4D79A8", 146 - border: "#141D23", 146 + border: "#1d252b", 147 + hoverColor: "#1E2A32", 147 148 buttonFocus: "#202836", 148 149 flagBg: "#202836", 149 150 inputBg: "#202836",