mirror of
https://github.com/tubearchivist/tubearchivist.git
synced 2025-02-19 06:20:13 +00:00
Various refresh fixes, #build
Changed: - Fix player focus - Various refresh fixes for player open/close - Refresh watch state - Add error messages schedules and login - Show help modal
This commit is contained in:
commit
2083828fe1
@ -140,6 +140,9 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi
|
||||
|
||||
This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers.
|
||||
|
||||
Note:
|
||||
- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers.
|
||||
|
||||
### Native Instruction
|
||||
|
||||
For convenience, it's recommended to still run Redis and ES in a docker container. Make sure both containers can be reachable over the network.
|
||||
@ -178,7 +181,7 @@ Then from the frontend folder, install the dependencies with:
|
||||
npm install
|
||||
```
|
||||
|
||||
Then to start the developlent server:
|
||||
Then to start the frontend development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
@ -55,7 +55,7 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm
|
||||
# install debug tools for testing environment
|
||||
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
||||
apt-get -y update && apt-get -y install --no-install-recommends \
|
||||
vim htop bmon net-tools iputils-ping procps \
|
||||
vim htop bmon net-tools iputils-ping procps lsof \
|
||||
&& pip install --user ipython pytest pytest-django \
|
||||
; fi
|
||||
|
||||
|
@ -275,3 +275,19 @@ def get_channel_overwrites() -> dict[str, dict[str, Any]]:
|
||||
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
|
||||
|
||||
return overwrites
|
||||
|
||||
|
||||
def calc_is_watched(duration: float, position: float) -> bool:
|
||||
"""considered watched based on duration position"""
|
||||
|
||||
if not duration or duration <= 0:
|
||||
return False
|
||||
|
||||
if duration < 60:
|
||||
threshold = 0.5
|
||||
elif duration > 900:
|
||||
threshold = 1 - (180 / duration)
|
||||
else:
|
||||
threshold = 0.9
|
||||
|
||||
return position >= duration * threshold
|
||||
|
@ -45,7 +45,11 @@ class SearchProcess:
|
||||
if not all_positions:
|
||||
return None
|
||||
|
||||
pos_index = {i["youtube_id"]: i["position"] for i in all_positions}
|
||||
pos_index = {
|
||||
i["youtube_id"]: i["position"]
|
||||
for i in all_positions
|
||||
if not i.get("watched")
|
||||
}
|
||||
return pos_index
|
||||
|
||||
def _process_result(self, result):
|
||||
@ -102,13 +106,15 @@ class SearchProcess:
|
||||
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
||||
channel = self._process_channel(video_dict["channel"])
|
||||
|
||||
cache_root = EnvironmentSettings().get_cache_root()
|
||||
media_root = EnvironmentSettings().get_media_root()
|
||||
|
||||
if "subtitles" in video_dict:
|
||||
for idx, _ in enumerate(video_dict["subtitles"]):
|
||||
url = video_dict["subtitles"][idx]["media_url"]
|
||||
video_dict["subtitles"][idx]["media_url"] = f"/media/{url}"
|
||||
|
||||
cache_root = EnvironmentSettings().get_cache_root()
|
||||
media_root = EnvironmentSettings().get_media_root()
|
||||
video_dict["subtitles"][idx][
|
||||
"media_url"
|
||||
] = f"{media_root}/{url}"
|
||||
|
||||
video_dict.update(
|
||||
{
|
||||
|
@ -6,15 +6,17 @@ functionality:
|
||||
from datetime import datetime
|
||||
|
||||
from common.src.es_connect import ElasticWrap
|
||||
from common.src.ta_redis import RedisArchivist
|
||||
from common.src.urlparser import Parser
|
||||
|
||||
|
||||
class WatchState:
|
||||
"""handle watched checkbox for videos and channels"""
|
||||
|
||||
def __init__(self, youtube_id, is_watched):
|
||||
def __init__(self, youtube_id: str, is_watched: bool, user_id: int):
|
||||
self.youtube_id = youtube_id
|
||||
self.is_watched = is_watched
|
||||
self.user_id = user_id
|
||||
self.stamp = int(datetime.now().timestamp())
|
||||
self.pipeline = f"_ingest/pipeline/watch_{youtube_id}"
|
||||
|
||||
@ -50,6 +52,8 @@ class WatchState:
|
||||
}
|
||||
}
|
||||
response, status_code = ElasticWrap(path).post(data=data)
|
||||
key = f"{self.user_id}:progress:{self.youtube_id}"
|
||||
RedisArchivist().del_message(key)
|
||||
if status_code != 200:
|
||||
print(response)
|
||||
raise ValueError("failed to mark video as watched")
|
||||
|
@ -75,7 +75,7 @@ class WatchedView(ApiBaseView):
|
||||
message = {"message": "missing id or is_watched"}
|
||||
return Response(message, status=400)
|
||||
|
||||
WatchState(youtube_id, is_watched).change()
|
||||
WatchState(youtube_id, is_watched, request.user.id).change()
|
||||
return Response({"message": "success"}, status=200)
|
||||
|
||||
|
||||
|
@ -82,23 +82,24 @@ class YtWrap:
|
||||
|
||||
def extract(self, url):
|
||||
"""make extract request"""
|
||||
try:
|
||||
response = yt_dlp.YoutubeDL(self.obs).extract_info(url)
|
||||
except cookiejar.LoadError as err:
|
||||
print(f"cookie file is invalid: {err}")
|
||||
return False
|
||||
except yt_dlp.utils.ExtractorError as err:
|
||||
print(f"{url}: failed to extract with message: {err}, continue...")
|
||||
return False
|
||||
except yt_dlp.utils.DownloadError as err:
|
||||
if "This channel does not have a" in str(err):
|
||||
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
||||
try:
|
||||
response = ydl.extract_info(url)
|
||||
except cookiejar.LoadError as err:
|
||||
print(f"cookie file is invalid: {err}")
|
||||
return False
|
||||
except yt_dlp.utils.ExtractorError as err:
|
||||
print(f"{url}: failed to extract: {err}, continue...")
|
||||
return False
|
||||
except yt_dlp.utils.DownloadError as err:
|
||||
if "This channel does not have a" in str(err):
|
||||
return False
|
||||
|
||||
print(f"{url}: failed to get info from youtube with message {err}")
|
||||
if "Temporary failure in name resolution" in str(err):
|
||||
raise ConnectionError("lost the internet, abort!") from err
|
||||
print(f"{url}: failed to get info from youtube: {err}")
|
||||
if "Temporary failure in name resolution" in str(err):
|
||||
raise ConnectionError("lost the internet, abort!") from err
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
self._validate_cookie()
|
||||
|
||||
@ -131,7 +132,8 @@ class CookieHandler:
|
||||
|
||||
def set_cookie(self, cookie):
|
||||
"""set cookie str and activate in config"""
|
||||
RedisArchivist().set_message("cookie", cookie, save=True)
|
||||
cookie_clean = cookie.strip("\x00")
|
||||
RedisArchivist().set_message("cookie", cookie_clean, save=True)
|
||||
AppConfig().update_config({"downloads.cookie_import": True})
|
||||
self.config["downloads"]["cookie_import"] = True
|
||||
print("[cookie]: activated and stored in Redis")
|
||||
@ -161,9 +163,10 @@ class CookieHandler:
|
||||
self.store_validation(response)
|
||||
|
||||
# update in redis to avoid expiring
|
||||
modified = validator.obs["cookiefile"].getvalue()
|
||||
modified = validator.obs["cookiefile"].getvalue().strip("\x00")
|
||||
if modified:
|
||||
RedisArchivist().set_message("cookie", modified)
|
||||
cookie_clean = modified.strip("\x00")
|
||||
RedisArchivist().set_message("cookie", cookie_clean)
|
||||
|
||||
if not response:
|
||||
mess_dict = {
|
||||
|
@ -20,7 +20,14 @@ class DownloadApiListView(ApiBaseView):
|
||||
def get(self, request):
|
||||
"""get request"""
|
||||
query_filter = request.GET.get("filter", False)
|
||||
self.data.update({"sort": [{"timestamp": {"order": "asc"}}]})
|
||||
self.data.update(
|
||||
{
|
||||
"sort": [
|
||||
{"auto_start": {"order": "desc"}},
|
||||
{"timestamp": {"order": "asc"}},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
must_list = []
|
||||
if query_filter:
|
||||
|
@ -63,10 +63,15 @@ class QueryBuilder:
|
||||
if not results:
|
||||
return None
|
||||
|
||||
ids = [{"match": {"youtube_id": i.get("youtube_id")}} for i in results]
|
||||
continue_ids = {"bool": {"should": ids}}
|
||||
ids = [
|
||||
{"match": {"youtube_id": i.get("youtube_id")}}
|
||||
for i in results
|
||||
if not i.get("watched")
|
||||
]
|
||||
if not ids:
|
||||
return None
|
||||
|
||||
return continue_ids
|
||||
return {"bool": {"should": ids}}
|
||||
|
||||
def parse_type(self, video_type: str):
|
||||
"""parse video type"""
|
||||
|
@ -1,6 +1,8 @@
|
||||
"""all API views for video endpoints"""
|
||||
|
||||
from common.src.helper import calc_is_watched
|
||||
from common.src.ta_redis import RedisArchivist
|
||||
from common.src.watched import WatchState
|
||||
from common.views_base import AdminWriteOnly, ApiBaseView
|
||||
from playlist.src.index import YoutubePlaylist
|
||||
from rest_framework.response import Response
|
||||
@ -102,18 +104,55 @@ class VideoProgressView(ApiBaseView):
|
||||
handle progress status for video
|
||||
"""
|
||||
|
||||
search_base = "ta_video/_doc/"
|
||||
|
||||
@staticmethod
|
||||
def _get_key(user_id: int, video_id: str) -> str:
|
||||
"""redis key"""
|
||||
return f"{user_id}:progress:{video_id}"
|
||||
|
||||
def post(self, request, video_id):
|
||||
"""set progress position in redis"""
|
||||
position = request.data.get("position", 0)
|
||||
key = f"{request.user.id}:progress:{video_id}"
|
||||
message = {"position": position, "youtube_id": video_id}
|
||||
RedisArchivist().set_message(key, message)
|
||||
self.response = request.data
|
||||
return Response(self.response)
|
||||
key = self._get_key(request.user.id, video_id)
|
||||
redis_con = RedisArchivist()
|
||||
current_progress = redis_con.get_message_dict(key)
|
||||
|
||||
if not current_progress:
|
||||
self.get_document(video_id)
|
||||
if self.status_code != 200:
|
||||
return Response(status=self.status_code)
|
||||
|
||||
current_progress = self.response["data"]["player"]
|
||||
|
||||
current_progress.update({"position": position, "youtube_id": video_id})
|
||||
watched = self._check_watched(request, video_id, current_progress)
|
||||
if watched:
|
||||
expire = 60
|
||||
else:
|
||||
expire = False
|
||||
|
||||
current_progress.update({"watched": watched})
|
||||
redis_con.set_message(key, current_progress, expire=expire)
|
||||
|
||||
return Response(current_progress)
|
||||
|
||||
def _check_watched(self, request, video_id, current_progress) -> bool:
|
||||
"""check watched state"""
|
||||
if current_progress["watched"]:
|
||||
return True
|
||||
|
||||
watched = calc_is_watched(
|
||||
current_progress["duration"], current_progress["position"]
|
||||
)
|
||||
if watched:
|
||||
WatchState(video_id, watched, request.user.id).change()
|
||||
|
||||
return watched
|
||||
|
||||
def delete(self, request, video_id):
|
||||
"""delete progress position"""
|
||||
key = f"{request.user.id}:progress:{video_id}"
|
||||
key = self._get_key(request.user.id, video_id)
|
||||
RedisArchivist().del_message(key)
|
||||
self.response = {"progress-reset": video_id}
|
||||
|
||||
|
@ -34,7 +34,7 @@ function sync_blackhole {
|
||||
--exclude ".mypy_cache" \
|
||||
. -e ssh "$host":tubearchivist
|
||||
|
||||
ssh "$host" 'docker build -t bbilly1/tubearchivist:unstable tubearchivist'
|
||||
ssh "$host" 'docker build --build-arg INSTALL_DEBUG=1 -t bbilly1/tubearchivist:unstable tubearchivist'
|
||||
ssh "$host" 'docker compose -f docker/docker-compose.yml up -d'
|
||||
|
||||
}
|
||||
|
@ -20,7 +20,11 @@ python manage.py ta_startup
|
||||
|
||||
# start all tasks
|
||||
nginx &
|
||||
celery -A task.celery worker --loglevel=INFO --max-tasks-per-child 10 &
|
||||
celery -A task.celery worker \
|
||||
--loglevel=INFO \
|
||||
--concurrency 4 \
|
||||
--max-tasks-per-child 5 \
|
||||
--max-memory-per-child 150000 &
|
||||
celery -A task beat --loglevel=INFO \
|
||||
--scheduler django_celery_beat.schedulers:DatabaseScheduler &
|
||||
python backend_start.py
|
||||
|
@ -12,6 +12,7 @@ src ┐
|
||||
│ ├───colours // Css loader for themes
|
||||
│ ├───constants // global constants that have no good place
|
||||
│ └───routes // Routes definitions used in Links and react-router-dom configuration
|
||||
├───functions // Useful functions
|
||||
└───pages // React components that define a page/route
|
||||
├───functions // Useful functions and hooks
|
||||
├───pages // React components that define a page/route
|
||||
└───stores // zustand stores
|
||||
```
|
||||
|
@ -1,11 +1,23 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
type VideoProgressResponseType = {
|
||||
watched: boolean;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
watched_date: number;
|
||||
position: number;
|
||||
youtube_id: string;
|
||||
};
|
||||
|
||||
type VideoProgressProp = {
|
||||
youtubeId: string;
|
||||
currentProgress: number;
|
||||
};
|
||||
|
||||
const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => {
|
||||
const updateVideoProgressById = async ({
|
||||
youtubeId,
|
||||
currentProgress,
|
||||
}: VideoProgressProp): Promise<VideoProgressResponseType> => {
|
||||
return APIClient(`/api/video/${youtubeId}/progress/`, {
|
||||
method: 'POST',
|
||||
body: { position: currentProgress },
|
||||
|
@ -28,52 +28,55 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refresh, setRefresh] = useState(true);
|
||||
|
||||
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
|
||||
const [playlists, setPlaylists] = useState<PlaylistList>();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const videoResponse = await loadVideoById(videoId);
|
||||
if (refresh || videoId !== videoResponse?.data.youtube_id) {
|
||||
const videoResponse = await loadVideoById(videoId);
|
||||
|
||||
const playlistIds = videoResponse.data.playlist;
|
||||
if (playlistIds !== undefined) {
|
||||
const playlists = await Promise.all(
|
||||
playlistIds.map(async playlistid => {
|
||||
const playlistResponse = await loadPlaylistById(playlistid);
|
||||
const playlistIds = videoResponse.data.playlist;
|
||||
if (playlistIds !== undefined) {
|
||||
const playlists = await Promise.all(
|
||||
playlistIds.map(async playlistid => {
|
||||
const playlistResponse = await loadPlaylistById(playlistid);
|
||||
|
||||
return playlistResponse.data;
|
||||
}),
|
||||
);
|
||||
return playlistResponse.data;
|
||||
}),
|
||||
);
|
||||
|
||||
const playlistsFiltered = playlists
|
||||
.filter(playlist => {
|
||||
return playlist.playlist_subscribed;
|
||||
})
|
||||
.map(playlist => {
|
||||
return {
|
||||
id: playlist.playlist_id,
|
||||
name: playlist.playlist_name,
|
||||
};
|
||||
});
|
||||
const playlistsFiltered = playlists
|
||||
.filter(playlist => {
|
||||
return playlist.playlist_subscribed;
|
||||
})
|
||||
.map(playlist => {
|
||||
return {
|
||||
id: playlist.playlist_id,
|
||||
name: playlist.playlist_name,
|
||||
};
|
||||
});
|
||||
|
||||
setPlaylists(playlistsFiltered);
|
||||
setPlaylists(playlistsFiltered);
|
||||
}
|
||||
|
||||
setVideoResponse(videoResponse);
|
||||
|
||||
inlinePlayerRef.current?.scrollIntoView();
|
||||
|
||||
setRefresh(false);
|
||||
}
|
||||
|
||||
setVideoResponse(videoResponse);
|
||||
|
||||
inlinePlayerRef.current?.scrollIntoView();
|
||||
|
||||
setRefresh(false);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [videoId, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
inlinePlayerRef.current?.scrollIntoView();
|
||||
}, []);
|
||||
|
||||
if (videoResponse === undefined) {
|
||||
return [];
|
||||
return <div ref={inlinePlayerRef} className="player-wrapper" />;
|
||||
}
|
||||
|
||||
const video = videoResponse.data;
|
||||
@ -94,14 +97,15 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
<>
|
||||
<div ref={inlinePlayerRef} className="player-wrapper">
|
||||
<div className="video-player">
|
||||
{!loading && (
|
||||
<VideoPlayer
|
||||
video={videoResponse}
|
||||
sponsorBlock={sponsorblock}
|
||||
embed={true}
|
||||
autoplay={true}
|
||||
/>
|
||||
)}
|
||||
<VideoPlayer
|
||||
video={videoResponse}
|
||||
sponsorBlock={sponsorblock}
|
||||
embed={true}
|
||||
autoplay={true}
|
||||
onWatchStateChanged={() => {
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="player-title boxed-content">
|
||||
<img
|
||||
@ -113,6 +117,7 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
setSearchParams({});
|
||||
}}
|
||||
/>
|
||||
|
||||
<WatchedCheckBox
|
||||
watched={watched}
|
||||
onClick={async status => {
|
||||
@ -125,12 +130,16 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{cast && (
|
||||
<GoogleCast
|
||||
video={video}
|
||||
setRefresh={() => {
|
||||
setRefresh(true);
|
||||
}}
|
||||
onWatchStateChanged={() => {
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { VideoType } from '../pages/Home';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import watchedThreshold from '../functions/watchedThreshold';
|
||||
|
||||
const getURL = () => {
|
||||
return window.location.origin;
|
||||
@ -29,6 +27,7 @@ async function castVideoProgress(
|
||||
duration: number;
|
||||
},
|
||||
video: VideoType | undefined,
|
||||
onWatchStateChanged?: (status: boolean) => void,
|
||||
) {
|
||||
if (!video) {
|
||||
console.log('castVideoProgress: Video to cast not found...');
|
||||
@ -42,19 +41,13 @@ async function castVideoProgress(
|
||||
|
||||
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
await updateVideoProgressById({
|
||||
const videoProgressResponse = await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (!video.player.watched) {
|
||||
// Check if video is already marked as watched
|
||||
if (watchedThreshold(currentTime, duration)) {
|
||||
await updateWatchedState({
|
||||
id: videoId,
|
||||
is_watched: true,
|
||||
});
|
||||
}
|
||||
if (videoProgressResponse.watched && video.player.watched !== videoProgressResponse.watched) {
|
||||
onWatchStateChanged?.(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,9 +86,10 @@ async function castVideoPaused(
|
||||
type GoogleCastProps = {
|
||||
video?: VideoType;
|
||||
setRefresh?: () => void;
|
||||
onWatchStateChanged?: (status: boolean) => void;
|
||||
};
|
||||
|
||||
const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => {
|
||||
const GoogleCast = ({ video, setRefresh, onWatchStateChanged }: GoogleCastProps) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const setup = useCallback(() => {
|
||||
@ -118,12 +112,14 @@ const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => {
|
||||
setIsConnected(player.isConnected);
|
||||
},
|
||||
);
|
||||
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
|
||||
function () {
|
||||
castVideoProgress(player, video);
|
||||
castVideoProgress(player, video, onWatchStateChanged);
|
||||
},
|
||||
);
|
||||
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
|
||||
function () {
|
||||
@ -131,6 +127,8 @@ const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => {
|
||||
setRefresh?.();
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setRefresh, video]);
|
||||
|
||||
const startPlayback = useCallback(() => {
|
||||
|
@ -3,6 +3,7 @@ import Routes from '../configuration/routes/RouteList';
|
||||
import { VideoType, ViewLayoutType } from '../pages/Home';
|
||||
import iconPlay from '/img/icon-play.svg';
|
||||
import iconDotMenu from '/img/icon-dot-menu.svg';
|
||||
import iconClose from '/img/icon-close.svg';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import formatDate from '../functions/formatDates';
|
||||
@ -10,6 +11,7 @@ import WatchedCheckBox from './WatchedCheckBox';
|
||||
import MoveVideoMenu from './MoveVideoMenu';
|
||||
import { useState } from 'react';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
|
||||
|
||||
type VideoListItemProps = {
|
||||
video: VideoType;
|
||||
@ -84,6 +86,17 @@ const VideoListItem = ({
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
{video.player.progress && (
|
||||
<img
|
||||
src={iconClose}
|
||||
className="video-popup-menu-close-button"
|
||||
title="Delete watch progress"
|
||||
onClick={async () => {
|
||||
await deleteVideoProgressById(video.youtube_id);
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{formatDate(video.published)} | {video.player.duration_str}
|
||||
</span>
|
||||
|
@ -1,11 +1,20 @@
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
|
||||
import watchedThreshold from '../functions/watchedThreshold';
|
||||
import { Dispatch, Fragment, SetStateAction, SyntheticEvent, useState } from 'react';
|
||||
import {
|
||||
Dispatch,
|
||||
Fragment,
|
||||
SetStateAction,
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import formatTime from '../functions/formatTime';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import { useKeyPress } from '../functions/useKeypressHook';
|
||||
|
||||
const VIDEO_PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3];
|
||||
|
||||
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
|
||||
|
||||
@ -54,6 +63,7 @@ const handleTimeUpdate =
|
||||
watched: boolean,
|
||||
sponsorBlock?: SponsorBlockType,
|
||||
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
|
||||
onWatchStateChanged?: (status: boolean) => void,
|
||||
) =>
|
||||
async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
@ -78,22 +88,16 @@ const handleTimeUpdate =
|
||||
});
|
||||
}
|
||||
|
||||
if (currentTime < 10) return;
|
||||
if (currentTime < 10 && currentTime === Number(videoTag.currentTarget.duration)) return;
|
||||
if (Number((currentTime % 10).toFixed(1)) <= 0.2) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
await updateVideoProgressById({
|
||||
const videoProgressResponse = await updateVideoProgressById({
|
||||
youtubeId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
// Check if video is already marked as watched
|
||||
if (watchedThreshold(currentTime, duration)) {
|
||||
await updateWatchedState({
|
||||
id: youtubeId,
|
||||
is_watched: true,
|
||||
});
|
||||
}
|
||||
if (videoProgressResponse.watched && watched !== videoProgressResponse.watched) {
|
||||
onWatchStateChanged?.(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -103,6 +107,7 @@ type VideoPlayerProps = {
|
||||
sponsorBlock?: SponsorBlockType;
|
||||
embed?: boolean;
|
||||
autoplay?: boolean;
|
||||
onWatchStateChanged?: (status: boolean) => void;
|
||||
onVideoEnd?: () => void;
|
||||
};
|
||||
|
||||
@ -111,12 +116,31 @@ const VideoPlayer = ({
|
||||
sponsorBlock,
|
||||
embed,
|
||||
autoplay = false,
|
||||
onWatchStateChanged,
|
||||
onVideoEnd,
|
||||
}: VideoPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const searchParamVideoProgress = searchParams.get('t');
|
||||
|
||||
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [playbackSpeedIndex, setPlaybackSpeedIndex] = useState(3);
|
||||
const [lastSubtitleTack, setLastSubtitleTack] = useState(0);
|
||||
const [showHelpDialog, setShowHelpDialog] = useState(false);
|
||||
const [showInfoDialog, setShowInfoDialog] = useState(false);
|
||||
const [infoDialogContent, setInfoDialogContent] = useState('');
|
||||
|
||||
const questionmarkPressed = useKeyPress('?');
|
||||
const mutePressed = useKeyPress('m');
|
||||
const fullscreenPressed = useKeyPress('f');
|
||||
const subtitlesPressed = useKeyPress('c');
|
||||
const increasePlaybackSpeedPressed = useKeyPress('>');
|
||||
const decreasePlaybackSpeedPressed = useKeyPress('<');
|
||||
const resetPlaybackSpeedPressed = useKeyPress('=');
|
||||
const arrowRightPressed = useKeyPress('ArrowRight');
|
||||
const arrowLeftPressed = useKeyPress('ArrowLeft');
|
||||
|
||||
const videoId = video.data.youtube_id;
|
||||
const videoUrl = video.data.media_url;
|
||||
@ -132,16 +156,32 @@ const VideoPlayer = ({
|
||||
videoSrcProgress = searchParamVideoProgress;
|
||||
}
|
||||
|
||||
const infoDialog = (content: string) => {
|
||||
setInfoDialogContent(content);
|
||||
setShowInfoDialog(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setShowInfoDialog(false);
|
||||
setInfoDialogContent('');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleVideoEnd =
|
||||
(
|
||||
youtubeId: string,
|
||||
watched: boolean,
|
||||
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
|
||||
) =>
|
||||
async () => {
|
||||
if (!watched) {
|
||||
// Check if video is already marked as watched
|
||||
await updateWatchedState({ id: youtubeId, is_watched: true });
|
||||
async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
|
||||
const videoProgressResponse = await updateVideoProgressById({
|
||||
youtubeId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (videoProgressResponse.watched && watched !== videoProgressResponse.watched) {
|
||||
onWatchStateChanged?.(true);
|
||||
}
|
||||
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
@ -157,11 +197,131 @@ const VideoPlayer = ({
|
||||
onVideoEnd?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mutePressed) {
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mutePressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (increasePlaybackSpeedPressed) {
|
||||
const newSpeed = playbackSpeedIndex + 1;
|
||||
|
||||
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeed]) {
|
||||
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeed];
|
||||
videoRef.current.playbackRate = speed;
|
||||
|
||||
setPlaybackSpeedIndex(newSpeed);
|
||||
infoDialog(`${speed}x`);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [increasePlaybackSpeedPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (decreasePlaybackSpeedPressed) {
|
||||
const newSpeedIndex = playbackSpeedIndex - 1;
|
||||
|
||||
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) {
|
||||
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex];
|
||||
videoRef.current.playbackRate = speed;
|
||||
|
||||
setPlaybackSpeedIndex(newSpeedIndex);
|
||||
infoDialog(`${speed}x`);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decreasePlaybackSpeedPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetPlaybackSpeedPressed) {
|
||||
const newSpeedIndex = 3;
|
||||
|
||||
if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) {
|
||||
const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex];
|
||||
videoRef.current.playbackRate = speed;
|
||||
|
||||
setPlaybackSpeedIndex(newSpeedIndex);
|
||||
infoDialog(`${speed}x`);
|
||||
}
|
||||
}
|
||||
}, [resetPlaybackSpeedPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fullscreenPressed) {
|
||||
if (videoRef.current && videoRef.current.requestFullscreen && !document.fullscreenElement) {
|
||||
videoRef.current.requestFullscreen().catch(e => {
|
||||
console.error(e);
|
||||
infoDialog('Unable to enter fullscreen');
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().catch(e => {
|
||||
console.error(e);
|
||||
infoDialog('Unable to exit fullscreen');
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [fullscreenPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitlesPressed) {
|
||||
if (videoRef.current) {
|
||||
const tracks = [...videoRef.current.textTracks];
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastIndex = tracks.findIndex(x => x.mode === 'showing');
|
||||
const active = tracks[lastIndex];
|
||||
|
||||
if (!active && lastSubtitleTack !== 0) {
|
||||
tracks[lastSubtitleTack - 1].mode = 'showing';
|
||||
} else {
|
||||
if (active) {
|
||||
active.mode = 'hidden';
|
||||
|
||||
setLastSubtitleTack(lastIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subtitlesPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (arrowLeftPressed) {
|
||||
infoDialog('- 5 seconds');
|
||||
}
|
||||
}, [arrowLeftPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (arrowRightPressed) {
|
||||
infoDialog('+ 5 seconds');
|
||||
}
|
||||
}, [arrowRightPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (questionmarkPressed) {
|
||||
if (!showHelpDialog) {
|
||||
setTimeout(() => {
|
||||
setShowHelpDialog(false);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
setShowHelpDialog(!showHelpDialog);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [questionmarkPressed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="player" className={embed ? '' : 'player-wrapper'}>
|
||||
<div className={embed ? '' : 'video-main'}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={`${getApiUrl()}${videoUrl}`}
|
||||
poster={`${getApiUrl()}${videoThumbUrl}`}
|
||||
onVolumeChange={(videoTag: VideoTag) => {
|
||||
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
|
||||
@ -181,11 +341,12 @@ const VideoPlayer = ({
|
||||
watched,
|
||||
sponsorBlock,
|
||||
setSkippedSegments,
|
||||
onWatchStateChanged,
|
||||
)}
|
||||
onPause={async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
|
||||
if (currentTime < 10) return;
|
||||
if (currentTime < 10 || currentTime > duration * 0.95) return;
|
||||
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
@ -198,6 +359,7 @@ const VideoPlayer = ({
|
||||
width="100%"
|
||||
playsInline
|
||||
id="video-item"
|
||||
muted={isMuted}
|
||||
>
|
||||
<source
|
||||
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
|
||||
@ -208,6 +370,60 @@ const VideoPlayer = ({
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog className="video-modal" open={showHelpDialog}>
|
||||
<div className="video-modal-text">
|
||||
<table className="video-modal-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Show help</td>
|
||||
<td>?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle mute</td>
|
||||
<td>m</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle fullscreen</td>
|
||||
<td>f</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle subtitles (if available)</td>
|
||||
<td>c</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Increase speed</td>
|
||||
<td>></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Decrease speed</td>
|
||||
<td><</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reset speed</td>
|
||||
<td>=</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Back 5 seconds</td>
|
||||
<td>←</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Forward 5 seconds</td>
|
||||
<td>→</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form className="video-modal-form" method="dialog">
|
||||
<button>Close</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog className="video-modal" open={showInfoDialog}>
|
||||
<div className="video-modal-text">{infoDialogContent}</div>
|
||||
</dialog>
|
||||
|
||||
<div className="sponsorblock" id="sponsorblock">
|
||||
{sponsorBlock?.is_enabled && (
|
||||
<>
|
||||
|
@ -10,6 +10,11 @@ export interface ApiClientOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: Record<string, unknown> | string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const APIClient = async (
|
||||
endpoint: string,
|
||||
{ method = 'GET', body, headers = {}, ...options }: ApiClientOptions = {},
|
||||
@ -30,6 +35,14 @@ const APIClient = async (
|
||||
});
|
||||
|
||||
// Handle common errors
|
||||
if (response.status === 400) {
|
||||
const data = await response.json();
|
||||
throw {
|
||||
status: response.status,
|
||||
message: data?.message || 'An error occurred while processing the request.',
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
logOut();
|
||||
window.location.href = Routes.Login;
|
||||
|
30
frontend/src/functions/useKeypressHook.tsx
Normal file
30
frontend/src/functions/useKeypressHook.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// source: https://thibault.sh/react-hooks/use-key-press
|
||||
export function useKeyPress(targetKey: string) {
|
||||
const [isKeyPressed, setIsKeyPressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === targetKey) {
|
||||
setIsKeyPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === targetKey) {
|
||||
setIsKeyPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [targetKey]);
|
||||
|
||||
return isKeyPressed;
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
function watchedThreshold(currentTime: number, duration: number) {
|
||||
let watched = false;
|
||||
|
||||
if (duration <= 1800) {
|
||||
// If video is less than 30 min
|
||||
if (currentTime / duration >= 0.9) {
|
||||
// Mark as watched at 90%
|
||||
watched = true;
|
||||
}
|
||||
} else {
|
||||
// If video is more than 30 min
|
||||
if (currentTime >= duration - 120) {
|
||||
// Mark as watched if there is two minutes left
|
||||
watched = true;
|
||||
}
|
||||
}
|
||||
|
||||
return watched;
|
||||
}
|
||||
|
||||
export default watchedThreshold;
|
@ -81,6 +81,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
||||
channelId,
|
||||
pagination?.current_page,
|
||||
videoType,
|
||||
showEmbeddedVideo,
|
||||
]);
|
||||
|
||||
if (!channel) {
|
||||
|
@ -60,7 +60,7 @@ const Channels = () => {
|
||||
|
||||
const channels = channelListResponse?.data;
|
||||
const pagination = channelListResponse?.paginate;
|
||||
const channelCount = pagination?.total_hits;
|
||||
// const channelCount = pagination?.total_hits;
|
||||
const hasChannels = channels?.length !== 0;
|
||||
|
||||
useEffect(() => {
|
||||
@ -184,7 +184,7 @@ const Channels = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasChannels && <h2>Total channels: {channelCount}</h2>}
|
||||
{/* {hasChannels && <h2>Total channels: {channelCount}</h2>} */}
|
||||
|
||||
<div className={`channel-list ${userConfig.config.view_style_channel}`}>
|
||||
{!hasChannels && <h2>No channels found...</h2>}
|
||||
|
@ -182,6 +182,7 @@ const Download = () => {
|
||||
label="Add to queue"
|
||||
onClick={async () => {
|
||||
await updateDownloadQueue(downloadQueueText, false);
|
||||
setDownloadQueueText('');
|
||||
setRefresh(true);
|
||||
setShowHiddenForm(false);
|
||||
}}
|
||||
@ -190,6 +191,7 @@ const Download = () => {
|
||||
label="Download now"
|
||||
onClick={async () => {
|
||||
await updateDownloadQueue(downloadQueueText, true);
|
||||
setDownloadQueueText('');
|
||||
setRefresh(true);
|
||||
setShowHiddenForm(false);
|
||||
}}
|
||||
|
@ -155,6 +155,7 @@ const Home = () => {
|
||||
userMeConfig.hide_watched,
|
||||
currentPage,
|
||||
pagination?.current_page,
|
||||
showEmbeddedVideo,
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -184,7 +185,7 @@ const Home = () => {
|
||||
<h1>Recent Videos</h1>
|
||||
</div>
|
||||
|
||||
<Filterbar hideToggleText="Hide watched:" viewStyleName={ViewStyleNames.home} />
|
||||
<Filterbar hideToggleText="Show unwatched only:" viewStyleName={ViewStyleNames.home} />
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
|
@ -6,18 +6,20 @@ import Button from '../components/Button';
|
||||
import signIn from '../api/actions/signIn';
|
||||
|
||||
const Login = () => {
|
||||
useColours();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saveLogin, setSaveLogin] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useColours();
|
||||
|
||||
const form_error = false;
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
const loginResponse = await signIn(username, password, saveLogin);
|
||||
|
||||
const signedIn = loginResponse.status === 200;
|
||||
@ -25,6 +27,8 @@ const Login = () => {
|
||||
if (signedIn) {
|
||||
navigate(Routes.Home);
|
||||
} else {
|
||||
const data = await loginResponse.json();
|
||||
setErrorMessage(data?.message || 'Unknown Error');
|
||||
navigate(Routes.Login);
|
||||
}
|
||||
};
|
||||
@ -37,7 +41,13 @@ const Login = () => {
|
||||
<h1>Tube Archivist</h1>
|
||||
<h2>Your Self Hosted YouTube Media Server</h2>
|
||||
|
||||
{form_error && <p className="danger-zone">Failed to login.</p>}
|
||||
{errorMessage !== null && (
|
||||
<p className="danger-zone">
|
||||
Failed to login.
|
||||
<br />
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
@ -51,7 +61,9 @@ const Login = () => {
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
@ -62,7 +74,9 @@ const Login = () => {
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<p>
|
||||
Remember me:{' '}
|
||||
<input
|
||||
@ -75,7 +89,9 @@ const Login = () => {
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<input type="hidden" name="next" value={Routes.Home} />
|
||||
|
||||
<Button label="Login" type="submit" />
|
||||
</form>
|
||||
<p className="login-links">
|
||||
|
@ -110,7 +110,14 @@ const Playlist = () => {
|
||||
setRefresh(false);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlistId, userConfig.config.hide_watched, refresh, currentPage, pagination?.current_page]);
|
||||
}, [
|
||||
playlistId,
|
||||
userConfig.config.hide_watched,
|
||||
refresh,
|
||||
currentPage,
|
||||
pagination?.current_page,
|
||||
showEmbeddedVideo,
|
||||
]);
|
||||
|
||||
if (!playlistId || !playlist) {
|
||||
return `Playlist ${playlistId} not found!`;
|
||||
|
@ -46,7 +46,8 @@ const Search = () => {
|
||||
const viewPlaylists = userMeConfig.view_style_playlist;
|
||||
const gridItems = userMeConfig.grid_items || 3;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState<string>('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResultsType>();
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
@ -58,7 +59,7 @@ const Search = () => {
|
||||
const queryType = searchResults?.queryType;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const hasSearchQuery = searchQuery.length > 0;
|
||||
const hasSearchQuery = searchTerm.length > 0;
|
||||
const hasVideos = Number(videoList?.length) > 0;
|
||||
const hasChannels = Number(channelList?.length) > 0;
|
||||
const hasPlaylist = Number(playlistList?.length) > 0;
|
||||
@ -75,19 +76,29 @@ const Search = () => {
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!hasSearchQuery) {
|
||||
setSearchResults(EmptySearchResponse);
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||