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:
Simon 2025-02-02 17:36:33 +07:00
commit 2083828fe1
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
31 changed files with 676 additions and 211 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(
{

View File

@ -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")

View File

@ -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)

View File

@ -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 = {

View File

@ -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:

View File

@ -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"""

View File

@ -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}

View File

@ -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'
}

View File

@ -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

View File

@ -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
```

View File

@ -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 },

View File

@ -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);
}}
/>
)}

View File

@ -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(() => {

View File

@ -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>

View File

@ -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>&gt;</td>
</tr>
<tr>
<td>Decrease speed</td>
<td>&lt;</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 && (
<>

View File

@ -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;

View 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;
}

View File

@ -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;

View File

@ -81,6 +81,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
channelId,
pagination?.current_page,
videoType,
showEmbeddedVideo,
]);
if (!channel) {

View File

@ -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>}

View File

@ -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);
}}

View File

@ -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}`}>

View File

@ -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">

View File

@ -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!`;

View File

@ -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);