Refac react frontend (#790)

* Add channel config endpoint

* Add channel aggs

* Add playlist show subscribed only toggle

* Fix refresh always on filterbar toggle

* Add loadingindicator for watchstate change

* Fix missing space in scheduling

* Add schedule request and apprisenotifcation

* Refac upgrade TypeScript target to include 2024 for Object.groupBy

* WIP: Schedule page

* WIP: Schedule page

* Add schedule management ( - notification )

* Fix missing space

* Refac show current selection in input

* Add apprise notifictation url

* Add Stream & Shorts channel pages

* Refac autotarget input on search page

* Fix input requiring 1 instead of 0

* Fix remove unused function

* Chore: npm audit fix

* Refac get channel_overwrites from channelById

* Refac remove defaultvalues form select

* Fix delay content refresh to allow the backend to update subscribed state

* Fix styling selection

* Fix lint

* Fix spelling

* Fix remove unused import

* Chore: update all dependencies - React 19 & vite 6

* Add missing property to ValidatedCookieType

* Refac fix complaints about JSX.Element, used ReactNode instead

* Refac remove unused dependency

* Refac replace react-helmet with react 19 implementation

* Fix Application Settings page

* Chore update dependencies

* Add simple playlist autoplay feature

* Refac use server provided channel images path

* Refac use server provided playlistthumbnail images path

* Add save and restore video playback speed
This commit is contained in:
Merlin
2024-12-22 15:59:30 +01:00
committed by GitHub
parent 75339e479e
commit 5a5d47da9b
54 changed files with 10047 additions and 9526 deletions

View File

@@ -1,254 +1,268 @@
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 Notifications from './Notifications';
import { Dispatch, SetStateAction, SyntheticEvent, useState } from 'react';
import formatTime from '../functions/formatTime';
import { useSearchParams } from 'react-router-dom';
import getApiUrl from '../configuration/getApiUrl';
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
export type SkippedSegmentType = {
from: number;
to: number;
};
export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>;
type Subtitle = {
name: string;
source: string;
lang: string;
media_url: string;
};
type SubtitlesProp = {
subtitles: Subtitle[];
};
const Subtitles = ({ subtitles }: SubtitlesProp) => {
return subtitles.map((subtitle: Subtitle) => {
let label = subtitle.name;
if (subtitle.source === 'auto') {
label += ' - auto';
}
return (
<track
key={subtitle.name}
label={label}
kind="subtitles"
srcLang={subtitle.lang}
src={`${getApiUrl()}${subtitle.media_url}`}
/>
);
});
};
const handleTimeUpdate =
(
youtubeId: string,
duration: number,
watched: boolean,
sponsorBlock?: SponsorBlockType,
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
) =>
async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (sponsorBlock && sponsorBlock.segments) {
sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
const [from, to] = segment.segment;
if (currentTime >= from && currentTime <= from + 0.3) {
videoTag.currentTarget.currentTime = to;
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from, to } };
});
}
if (currentTime > to + 10) {
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
});
}
});
}
if (currentTime < 10) 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({
youtubeId,
currentProgress: currentTime,
});
if (!watched) {
// Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({
id: youtubeId,
is_watched: true,
});
}
}
}
};
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 });
}
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
const keys = Object.keys(segments);
keys.forEach(uuid => {
segments[uuid] = { from: 0, to: 0 };
});
return segments;
});
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
};
const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
const autoplay = false;
return (
<>
<div id="player" className={embed ? '' : 'player-wrapper'}>
<div className={embed ? '' : 'video-main'}>
<video
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
}}
onTimeUpdate={handleTimeUpdate(
videoId,
duration,
watched,
sponsorBlock,
setSkippedSegments,
)}
onPause={async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (currentTime < 10) return;
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}}
onEnded={handleVideoEnd(videoId, watched)}
autoPlay={autoplay}
controls
width="100%"
playsInline
id="video-item"
>
<source
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
type="video/mp4"
id="video-source"
/>
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
</video>
</div>
</div>
<Notifications pageName="all" />
<div className="sponsorblock" id="sponsorblock">
{sponsorBlock?.is_enabled && (
<>
{sponsorBlock.segments.length == 0 && (
<h4>
This video doesn't have any sponsor segments added. To add a segment go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '}
and add a segment using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '}
extension.
</h4>
)}
{sponsorBlock.has_unlocked && (
<h4>
This video has unlocked sponsor segments. Go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '}
and vote on the segments using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '}
extension.
</h4>
)}
{Object.values(skippedSegments).map(({ from, to }) => {
return (
<>
{from !== 0 && to !== 0 && (
<h3>
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
</h3>
)}
</>
);
})}
</>
)}
</div>
</>
);
};
export default VideoPlayer;
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 Notifications from './Notifications';
import { Dispatch, SetStateAction, SyntheticEvent, useState } from 'react';
import formatTime from '../functions/formatTime';
import { useSearchParams } from 'react-router-dom';
import getApiUrl from '../configuration/getApiUrl';
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
export type SkippedSegmentType = {
from: number;
to: number;
};
export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>;
type Subtitle = {
name: string;
source: string;
lang: string;
media_url: string;
};
type SubtitlesProp = {
subtitles: Subtitle[];
};
const Subtitles = ({ subtitles }: SubtitlesProp) => {
return subtitles.map((subtitle: Subtitle) => {
let label = subtitle.name;
if (subtitle.source === 'auto') {
label += ' - auto';
}
return (
<track
key={subtitle.name}
label={label}
kind="subtitles"
srcLang={subtitle.lang}
src={`${getApiUrl()}${subtitle.media_url}`}
/>
);
});
};
const handleTimeUpdate =
(
youtubeId: string,
duration: number,
watched: boolean,
sponsorBlock?: SponsorBlockType,
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
) =>
async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (sponsorBlock && sponsorBlock.segments) {
sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
const [from, to] = segment.segment;
if (currentTime >= from && currentTime <= from + 0.3) {
videoTag.currentTarget.currentTime = to;
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from, to } };
});
}
if (currentTime > to + 10) {
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
});
}
});
}
if (currentTime < 10) 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({
youtubeId,
currentProgress: currentTime,
});
if (!watched) {
// Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({
id: youtubeId,
is_watched: true,
});
}
}
}
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
autoplay?: boolean;
onVideoEnd?: () => void;
};
const VideoPlayer = ({
video,
videoProgress,
sponsorBlock,
embed,
autoplay = false,
onVideoEnd,
}: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
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 });
}
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
const keys = Object.keys(segments);
keys.forEach(uuid => {
segments[uuid] = { from: 0, to: 0 };
});
return segments;
});
onVideoEnd?.();
};
return (
<>
<div id="player" className={embed ? '' : 'player-wrapper'}>
<div className={embed ? '' : 'video-main'}>
<video
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onRateChange={(videoTag: VideoTag) => {
localStorage.setItem('playerSpeed', videoTag.currentTarget.playbackRate.toString());
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
videoTag.currentTarget.playbackRate =
Number(localStorage.getItem('playerSpeed')) ?? 1;
}}
onTimeUpdate={handleTimeUpdate(
videoId,
duration,
watched,
sponsorBlock,
setSkippedSegments,
)}
onPause={async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (currentTime < 10) return;
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}}
onEnded={handleVideoEnd(videoId, watched)}
autoPlay={autoplay}
controls
width="100%"
playsInline
id="video-item"
>
<source
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
type="video/mp4"
id="video-source"
/>
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
</video>
</div>
</div>
<Notifications pageName="all" />
<div className="sponsorblock" id="sponsorblock">
{sponsorBlock?.is_enabled && (
<>
{sponsorBlock.segments.length == 0 && (
<h4>
This video doesn't have any sponsor segments added. To add a segment go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '}
and add a segment using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '}
extension.
</h4>
)}
{sponsorBlock.has_unlocked && (
<h4>
This video has unlocked sponsor segments. Go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '}
and vote on the segments using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '}
extension.
</h4>
)}
{Object.values(skippedSegments).map(({ from, to }) => {
return (
<>
{from !== 0 && to !== 0 && (
<h3>
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
</h3>
)}
</>
);
})}
</>
)}
</div>
</>
);
};
export default VideoPlayer;