mirror of
https://github.com/fergalmoran/xtreamium.git
synced 2025-12-22 09:41:33 +00:00
Not gonna work
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"postcss": "^8.4.12",
|
"postcss": "^8.4.12",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.3.1",
|
||||||
"react-router-dom": "6",
|
"react-router-dom": "6",
|
||||||
"react-scripts": "5.0.0",
|
"react-scripts": "5.0.0",
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"@types/jest": "^27.0.1",
|
"@types/jest": "^27.0.1",
|
||||||
"@types/node": "^16.7.13",
|
"@types/node": "^16.7.13",
|
||||||
"@types/react": "^17.0.20",
|
"@types/react": "^17.0.20",
|
||||||
"@types/react-dom": "^17.0.9"
|
"@types/react-dom": "^17.0.9",
|
||||||
|
"@types/react-helmet": "^6.1.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
<!-- <script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||||
<script>
|
<script>
|
||||||
window['__onGCastApiAvailable'] = function (isAvailable) {
|
window['__onGCastApiAvailable'] = function (isAvailable) {
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script> -->
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -37,7 +37,6 @@ const Header = () => {
|
|||||||
return (
|
return (
|
||||||
<header className="z-40 py-4 bg-white shadow-bottom dark:bg-gray-800">
|
<header className="z-40 py-4 bg-white shadow-bottom dark:bg-gray-800">
|
||||||
<div className="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
|
<div className="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
|
||||||
{/* <!-- Mobile hamburger --> */}
|
|
||||||
<button
|
<button
|
||||||
className="p-1 mr-5 -ml-1 rounded-md lg:hidden focus:outline-none focus:shadow-outline-purple"
|
className="p-1 mr-5 -ml-1 rounded-md lg:hidden focus:outline-none focus:shadow-outline-purple"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
@@ -45,7 +44,6 @@ const Header = () => {
|
|||||||
>
|
>
|
||||||
<MenuIcon className="w-6 h-6" aria-hidden="true" />
|
<MenuIcon className="w-6 h-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{/* <!-- Search input --> */}
|
|
||||||
<div className="flex justify-center flex-1 lg:mr-32">
|
<div className="flex justify-center flex-1 lg:mr-32">
|
||||||
<div className="relative w-full max-w-xl mr-6 focus-within:text-purple-500">
|
<div className="relative w-full max-w-xl mr-6 focus-within:text-purple-500">
|
||||||
<div className="absolute inset-y-0 flex items-center pl-2">
|
<div className="absolute inset-y-0 flex items-center pl-2">
|
||||||
@@ -60,7 +58,6 @@ const Header = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex items-center flex-shrink-0 space-x-6">
|
<ul className="flex items-center flex-shrink-0 space-x-6">
|
||||||
{/* <!-- Theme toggler --> */}
|
|
||||||
<li className="flex">
|
<li className="flex">
|
||||||
<button
|
<button
|
||||||
className="rounded-md focus:outline-none focus:shadow-outline-purple"
|
className="rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SidebarContext } from "./sidebar.context";
|
import { SidebarContext, SidebarProvider } from "./sidebar.context";
|
||||||
import { ThemeContext } from "./theme.context";
|
import { ThemeContext, ThemeProvider } from "./theme.context";
|
||||||
|
|
||||||
export { SidebarContext, ThemeContext };
|
export { SidebarContext, ThemeContext, SidebarProvider, ThemeProvider };
|
||||||
|
|||||||
@@ -9,26 +9,26 @@ interface ISidebarProviderContext {
|
|||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
}
|
}
|
||||||
export const SidebarContext = React.createContext<ISidebarProviderContext>({
|
export const SidebarContext = React.createContext<ISidebarProviderContext>({
|
||||||
isSidebarOpen: false,
|
isSidebarOpen: true,
|
||||||
toggleSidebar: () => {},
|
toggleSidebar: () => {},
|
||||||
closeSidebar: () => {},
|
closeSidebar: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SidebarProvider = ({ children }: ISidebarProvider) => {
|
export const SidebarProvider = ({ children }: ISidebarProvider) => {
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = React.useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const _toggleSidebar = () => {
|
||||||
setIsSidebarOpen(!isSidebarOpen);
|
setIsSidebarOpen(!isSidebarOpen);
|
||||||
};
|
};
|
||||||
const closeSidebar = () => {
|
const _closeSidebar = () => {
|
||||||
setIsSidebarOpen(false);
|
setIsSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = React.useMemo(
|
const value = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isSidebarOpen,
|
isSidebarOpen,
|
||||||
toggleSidebar,
|
toggleSidebar: _toggleSidebar,
|
||||||
closeSidebar,
|
closeSidebar: _closeSidebar,
|
||||||
}),
|
}),
|
||||||
[isSidebarOpen]
|
[isSidebarOpen]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import ReactDOM from "react-dom";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import reportWebVitals from "./reportWebVitals";
|
import reportWebVitals from "./reportWebVitals";
|
||||||
|
import { SidebarProvider } from "./context";
|
||||||
|
import { Windmill } from "@windmill/react-ui";
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<SidebarProvider>
|
||||||
|
<Windmill usePreferences>
|
||||||
|
<App />
|
||||||
|
</Windmill>
|
||||||
|
</SidebarProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById("root")
|
document.getElementById("root")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,11 +16,21 @@ import { FaChromecast } from "react-icons/fa";
|
|||||||
import { Stream } from "../models/stream";
|
import { Stream } from "../models/stream";
|
||||||
import { convertEpochToSpecificTimezone } from "../utils/date-utils";
|
import { convertEpochToSpecificTimezone } from "../utils/date-utils";
|
||||||
import { EPGComponent } from "../components";
|
import { EPGComponent } from "../components";
|
||||||
|
import {
|
||||||
|
CastButton,
|
||||||
|
CastProvider,
|
||||||
|
useCast,
|
||||||
|
useMedia,
|
||||||
|
} from "../utils/chromecast";
|
||||||
|
|
||||||
const ChannelPage = () => {
|
const ChannelPage = () => {
|
||||||
let params = useParams();
|
let params = useParams();
|
||||||
|
const cast = useCast();
|
||||||
|
const media = useMedia();
|
||||||
|
|
||||||
const [streams, setStreams] = React.useState<Stream[]>([]);
|
const [streams, setStreams] = React.useState<Stream[]>([]);
|
||||||
|
const [currentVideoUrl, setCurrentVideoUrl] = React.useState("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchChannels = async () => {
|
const fetchChannels = async () => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -33,10 +43,7 @@ const ChannelPage = () => {
|
|||||||
fetchChannels().catch(console.error);
|
fetchChannels().catch(console.error);
|
||||||
}, [params.channelId]);
|
}, [params.channelId]);
|
||||||
|
|
||||||
const handleXHR = (...args: any[]) => {
|
const _getStreamUrl = async (streamId: number) => {
|
||||||
console.log("channel.page", "handleXHR", args);
|
|
||||||
};
|
|
||||||
const playStream = async (streamId: number) => {
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.REACT_APP_API_URL}/live/stream/url/${streamId}`
|
`${process.env.REACT_APP_API_URL}/live/stream/url/${streamId}`
|
||||||
);
|
);
|
||||||
@@ -45,7 +52,25 @@ const ChannelPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.url) {
|
return data?.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _cast = React.useCallback(
|
||||||
|
async (streamId: number) => {
|
||||||
|
const streamUrl = await _getStreamUrl(streamId);
|
||||||
|
if (streamUrl) {
|
||||||
|
await media.playMedia(streamUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[media]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleXHR = (...args: any[]) => {
|
||||||
|
console.log("channel.page", "handleXHR", args);
|
||||||
|
};
|
||||||
|
const playStream = async (streamId: number) => {
|
||||||
|
const url = await _getStreamUrl(streamId);
|
||||||
|
if (url) {
|
||||||
const mpv_args =
|
const mpv_args =
|
||||||
"--keep-open=yes\n--geometry=1024x768-0-0\n--ontop\n--screen=2\n--ytdl-format=bestvideo[ext=mp4][height<=?720]+bestaudio[ext=m4a]\n--border=no".split(
|
"--keep-open=yes\n--geometry=1024x768-0-0\n--ontop\n--screen=2\n--ytdl-format=bestvideo[ext=mp4][height<=?720]+bestaudio[ext=m4a]\n--border=no".split(
|
||||||
/\n/
|
/\n/
|
||||||
@@ -53,7 +78,7 @@ const ChannelPage = () => {
|
|||||||
|
|
||||||
const query =
|
const query =
|
||||||
`?play_url=` +
|
`?play_url=` +
|
||||||
encodeURIComponent(data.url) +
|
encodeURIComponent(url) +
|
||||||
[""].concat(mpv_args.map(encodeURIComponent)).join("&mpv_args=");
|
[""].concat(mpv_args.map(encodeURIComponent)).join("&mpv_args=");
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
@@ -63,89 +88,64 @@ const ChannelPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<TableContainer className="mb-8">
|
<CastProvider>
|
||||||
<Table>
|
<TableContainer className="mb-8">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<tr>
|
<TableHeader>
|
||||||
<TableCell>Channel</TableCell>
|
<tr>
|
||||||
<TableCell>Type</TableCell>
|
<TableCell>Channel</TableCell>
|
||||||
<TableCell></TableCell>
|
<TableCell>Type</TableCell>
|
||||||
</tr>
|
<TableCell></TableCell>
|
||||||
</TableHeader>
|
</tr>
|
||||||
<TableBody>
|
</TableHeader>
|
||||||
{streams.map((stream: Stream) => [
|
<TableBody>
|
||||||
<TableRow key={stream.num}>
|
{streams.map((stream: Stream) => [
|
||||||
<TableCell>
|
<TableRow key={stream.num}>
|
||||||
<div className="flex items-center text-sm">
|
<TableCell>
|
||||||
<Avatar
|
<div className="flex items-center text-sm">
|
||||||
className="hidden w-10 h-10 ml-2 mr-3 md:block"
|
<Avatar
|
||||||
src={stream.stream_icon}
|
className="hidden w-10 h-10 ml-2 mr-3 md:block"
|
||||||
alt="Stream icon"
|
src={stream.stream_icon}
|
||||||
/>
|
alt="Stream icon"
|
||||||
<div>
|
|
||||||
<p className="font-semibold">{stream.name}</p>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
Added: {convertEpochToSpecificTimezone(stream.added)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge type={`primary`}>{stream.stream_type}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Button
|
|
||||||
layout="link"
|
|
||||||
size="small"
|
|
||||||
aria-label="Edit"
|
|
||||||
onClick={() => playStream(stream.stream_id)}
|
|
||||||
>
|
|
||||||
<AiOutlinePlayCircle
|
|
||||||
className="w-6 h-6"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
</Button>
|
<div>
|
||||||
<Button
|
<p className="font-semibold">{stream.name}</p>
|
||||||
layout="link"
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
size="small"
|
Added: {convertEpochToSpecificTimezone(stream.added)}
|
||||||
aria-label="Delete"
|
</p>
|
||||||
onClick={() => {
|
</div>
|
||||||
var mediaInfo = new chrome.cast.media.MediaInfo(
|
</div>
|
||||||
currentMediaURL,
|
</TableCell>
|
||||||
contentType
|
<TableCell>
|
||||||
);
|
<Badge type={`primary`}>{stream.stream_type}</Badge>
|
||||||
var request = new chrome.cast.media.LoadRequest(
|
</TableCell>
|
||||||
mediaInfo
|
<TableCell>
|
||||||
);
|
<div className="flex items-center space-x-4">
|
||||||
castSession.loadMedia(request).then(
|
<Button
|
||||||
function () {
|
icon={AiOutlinePlayCircle}
|
||||||
console.log("Load succeed");
|
layout="link"
|
||||||
},
|
aria-label="Edit"
|
||||||
function (errorCode) {
|
onClick={() => playStream(stream.stream_id)}
|
||||||
console.log("Error code: " + errorCode);
|
>
|
||||||
}
|
</Button>
|
||||||
);
|
<CastButton streamId={stream.stream_id} onPlay={_cast} />
|
||||||
}}
|
</div>
|
||||||
>
|
</TableCell>
|
||||||
<FaChromecast className="w-5 h-5" aria-hidden="true" />
|
</TableRow>,
|
||||||
</Button>
|
<tr key={`${stream.num}-epg`}>
|
||||||
</div>
|
{false && (
|
||||||
</TableCell>
|
<td colSpan={3} className="px-4 py-2 mt-8 border-4 shadow-md">
|
||||||
</TableRow>,
|
<Suspense fallback={<h1>Loading epg</h1>}>
|
||||||
<tr key={`${stream.num}-epg`}>
|
<EPGComponent channelId={stream.epg_channel_id} />
|
||||||
{false && (
|
</Suspense>
|
||||||
<td colSpan={3} className="px-4 py-2 mt-8 border-4 shadow-md">
|
</td>
|
||||||
<Suspense fallback={<h1>Loading epg</h1>}>
|
)}
|
||||||
<EPGComponent channelId={stream.epg_channel_id} />
|
</tr>,
|
||||||
</Suspense>
|
])}
|
||||||
</td>
|
</TableBody>
|
||||||
)}
|
</Table>
|
||||||
</tr>,
|
</TableContainer>
|
||||||
])}
|
</CastProvider>
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
33
frontend/src/utils/chromecast/CastButton.tsx
Normal file
33
frontend/src/utils/chromecast/CastButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useCast from "./useCast";
|
||||||
|
import { Button } from "@windmill/react-ui";
|
||||||
|
import { FaChromecast } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface ICastButtonProps {
|
||||||
|
streamId: number;
|
||||||
|
onPlay: (streamId: number) => void;
|
||||||
|
}
|
||||||
|
const CastButton = ({ streamId, onPlay }: ICastButtonProps) => {
|
||||||
|
const cast = useCast({
|
||||||
|
initialize_media_player: "DEFAULT_MEDIA_RECEIVER_APP_ID",
|
||||||
|
auto_initialize: true,
|
||||||
|
});
|
||||||
|
const handleClick = React.useCallback(async () => {
|
||||||
|
if (cast.castReceiver) {
|
||||||
|
await cast.handleConnection();
|
||||||
|
onPlay(streamId);
|
||||||
|
}
|
||||||
|
}, [cast.castReceiver, cast.handleConnection]);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
layout="link"
|
||||||
|
size="small"
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<FaChromecast className="w-5 h-5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CastButton;
|
||||||
72
frontend/src/utils/chromecast/CastProvider.tsx
Normal file
72
frontend/src/utils/chromecast/CastProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
import castContext from "./castContext";
|
||||||
|
import CastReceiver from "./CastReceiver";
|
||||||
|
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
const wait = (time: number) =>
|
||||||
|
new Promise((res) => {
|
||||||
|
setTimeout(res, time);
|
||||||
|
});
|
||||||
|
|
||||||
|
function CastProvider({ children }: { children: any }) {
|
||||||
|
const [cast, setCast] = useState<{
|
||||||
|
castReceiver?: CastReceiver;
|
||||||
|
castSender?: any;
|
||||||
|
}>({});
|
||||||
|
const [session, setSession] = useState<any>({});
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
let toBreak = false;
|
||||||
|
let tries = 15;
|
||||||
|
let castReceiver: CastReceiver;
|
||||||
|
let castSender: any;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
castReceiver = window.chrome.cast as CastReceiver;
|
||||||
|
// @ts-ignore
|
||||||
|
castSender = window.cast.framework as any;
|
||||||
|
toBreak = true;
|
||||||
|
} catch (err) {
|
||||||
|
tries--;
|
||||||
|
if (!tries) {
|
||||||
|
toBreak = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (toBreak) break;
|
||||||
|
}
|
||||||
|
await wait(95);
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
if (tries !== 0 && !!castReceiver) {
|
||||||
|
setCast({
|
||||||
|
castReceiver,
|
||||||
|
castSender,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Can't Load castReceiver and\\or castSender");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" />
|
||||||
|
<script src="//www.gstatic.com/cast/sdk/libs/receiver/2.0.0/cast_receiver.js" />
|
||||||
|
</Helmet>
|
||||||
|
<castContext.Provider
|
||||||
|
value={{
|
||||||
|
...cast,
|
||||||
|
session,
|
||||||
|
setSession,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</castContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CastProvider;
|
||||||
21
frontend/src/utils/chromecast/CastReceiver.tsx
Normal file
21
frontend/src/utils/chromecast/CastReceiver.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export default interface CastReceiver {
|
||||||
|
SessionRequest: new (...args: Array<any>) => any;
|
||||||
|
media: {
|
||||||
|
MediaInfo: new (p: string) => any;
|
||||||
|
LoadRequest: new (p: string) => any;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
Capability: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
ApiConfig: new (...args: Array<any>) => any;
|
||||||
|
initialize: (
|
||||||
|
ApiConfig: any,
|
||||||
|
initSucess: (e: any) => void,
|
||||||
|
initError: (e: any) => void
|
||||||
|
) => void;
|
||||||
|
requestSession: (
|
||||||
|
initSucess: (e: any) => void,
|
||||||
|
initError: (e: any) => void
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
11
frontend/src/utils/chromecast/castContext.ts
Normal file
11
frontend/src/utils/chromecast/castContext.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import CastReceiver from "./CastReceiver";
|
||||||
|
|
||||||
|
const castContext = createContext<{
|
||||||
|
castReceiver?: CastReceiver;
|
||||||
|
castSender?: any;
|
||||||
|
setSession?: (p: any) => void;
|
||||||
|
session?: any;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
export default castContext;
|
||||||
6
frontend/src/utils/chromecast/index.ts
Normal file
6
frontend/src/utils/chromecast/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import CastButton from "./CastButton";
|
||||||
|
import CastProvider from "./CastProvider";
|
||||||
|
import useCast from "./useCast";
|
||||||
|
import useMedia from "./useMedia";
|
||||||
|
|
||||||
|
export { CastProvider, CastButton, useMedia, useCast };
|
||||||
143
frontend/src/utils/chromecast/useCast.ts
Normal file
143
frontend/src/utils/chromecast/useCast.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useContext, useCallback, useState } from "react";
|
||||||
|
import castContext from "./castContext";
|
||||||
|
import CastReceiver from "./CastReceiver";
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* @param initialize_media_player - is media_receiver id
|
||||||
|
*
|
||||||
|
* if you pass auto_initialize as true you should pass this
|
||||||
|
*
|
||||||
|
* you should pass media_receiver id or 'DEFAULT_MEDIA_RECEIVER_APP_ID' to use default media receiver
|
||||||
|
*/
|
||||||
|
initialize_media_player?: string;
|
||||||
|
/**
|
||||||
|
* @param auto_initialize - you can use this to auto initialize cast media player when castReceiver was define
|
||||||
|
*
|
||||||
|
* you not need to pass nothing to this if you go initialize by yourself
|
||||||
|
*/
|
||||||
|
auto_initialize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cast {
|
||||||
|
/**
|
||||||
|
* Function to initialize cast player before connect to chromecast
|
||||||
|
*
|
||||||
|
* This function should wait for castReceiver
|
||||||
|
*
|
||||||
|
* @param media_player - you should pass media_receiver id or 'DEFAULT_MEDIA_RECEIVER_APP_ID' to use default media receiver
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```jsx
|
||||||
|
* const cast = useCast()
|
||||||
|
* useEffect(() => {
|
||||||
|
* if(cast.castReceiver){
|
||||||
|
* cast.initializeCast('DEFAULT_MEDIA_RECEIVER_APP_ID')
|
||||||
|
* }
|
||||||
|
* }, [cast.castReceiver])
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
initializeCast?: (media_player: string) => void;
|
||||||
|
/**
|
||||||
|
* Function to connect and disconnect client to chromecast
|
||||||
|
*/
|
||||||
|
handleConnection: () => Promise<any>;
|
||||||
|
/**
|
||||||
|
* castReceiver object, from cast_receiver google lib
|
||||||
|
*/
|
||||||
|
castReceiver?: CastReceiver;
|
||||||
|
/**
|
||||||
|
* castSender object, from cast_sender google lib
|
||||||
|
*/
|
||||||
|
castSender?: any;
|
||||||
|
/**
|
||||||
|
* this inidicate if client is connected with chromecast or not
|
||||||
|
*/
|
||||||
|
isConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCast(props?: Props) {
|
||||||
|
const { initialize_media_player, auto_initialize } = props || {};
|
||||||
|
|
||||||
|
const { castReceiver, castSender, setSession } = useContext(castContext);
|
||||||
|
const [cast, setCast] = useState({});
|
||||||
|
const [isConnect, setIsConnect] = useState(false);
|
||||||
|
const initiliazeCast = useCallback(
|
||||||
|
(media_player: string) => {
|
||||||
|
if (!castReceiver) return;
|
||||||
|
const sessionRequest = new castReceiver.SessionRequest(
|
||||||
|
castReceiver.media[media_player]
|
||||||
|
);
|
||||||
|
const apiConfig = new castReceiver.ApiConfig(
|
||||||
|
// @ts-ignore
|
||||||
|
sessionRequest,
|
||||||
|
(e: any) => {
|
||||||
|
// console.log("ss listener", e);
|
||||||
|
if (setSession) setSession(e);
|
||||||
|
setIsConnect(true);
|
||||||
|
},
|
||||||
|
(e: any) => {
|
||||||
|
// console.log("rc listener", e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
castReceiver.initialize(
|
||||||
|
apiConfig,
|
||||||
|
(e: any) => {
|
||||||
|
// console.log("init success", e);
|
||||||
|
},
|
||||||
|
(e: any) => {
|
||||||
|
// console.log("init error", e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setCast({
|
||||||
|
castReceiver,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[castReceiver, setSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConnection = useCallback(
|
||||||
|
() =>
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
if (castReceiver) {
|
||||||
|
// @ts-ignore
|
||||||
|
castReceiver.requestSession(
|
||||||
|
(e: any) => {
|
||||||
|
if (setSession) setSession(e);
|
||||||
|
setIsConnect(true);
|
||||||
|
res(e);
|
||||||
|
},
|
||||||
|
(e: any) => {
|
||||||
|
setIsConnect(false);
|
||||||
|
if (!isConnect) return rej(e);
|
||||||
|
return res(null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[castReceiver, setSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (castReceiver) {
|
||||||
|
setCast({ castReceiver });
|
||||||
|
if (auto_initialize && !initialize_media_player)
|
||||||
|
throw new Error(
|
||||||
|
"if you pass auto_initialize: true, you should pass initialize_media_player"
|
||||||
|
);
|
||||||
|
else if (auto_initialize && initialize_media_player)
|
||||||
|
initiliazeCast(initialize_media_player);
|
||||||
|
}
|
||||||
|
}, [castReceiver, castSender]);
|
||||||
|
|
||||||
|
const Cast = { ...cast, handleConnection, isConnect } as Cast;
|
||||||
|
|
||||||
|
if (!auto_initialize) {
|
||||||
|
Cast["initializeCast"] = initiliazeCast;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCast;
|
||||||
134
frontend/src/utils/chromecast/useMedia.ts
Normal file
134
frontend/src/utils/chromecast/useMedia.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useCallback, useContext, useState, useEffect } from "react";
|
||||||
|
import castContext from "./castContext";
|
||||||
|
|
||||||
|
interface Media {
|
||||||
|
/**
|
||||||
|
* @function playMedia - function to add an media url to chromecast to play, you should use this to first media before add more with the add function, and before use play function
|
||||||
|
* @param src - this should be an media url acessible by chromecast
|
||||||
|
* @param autoplay - this inidicate if media will play after defined, default is true
|
||||||
|
*/
|
||||||
|
playMedia: (src: string, autoplay?: boolean) => Promise<any>;
|
||||||
|
/**
|
||||||
|
* @function addMedia - function to add an media url to chromecast queue
|
||||||
|
* @param src - this should be an media url acessible by chromecast
|
||||||
|
*/
|
||||||
|
addMedia: (src: string) => Promise<any>;
|
||||||
|
/**
|
||||||
|
* @function play - function to play media in chromecast
|
||||||
|
*/
|
||||||
|
play: () => Promise<any>;
|
||||||
|
/**
|
||||||
|
* @function pause - function to pause media in chromecast
|
||||||
|
*/
|
||||||
|
pause: () => Promise<any>;
|
||||||
|
/**
|
||||||
|
* this inidicate if is a media connected to chromecast
|
||||||
|
*/
|
||||||
|
isMedia: boolean;
|
||||||
|
/**
|
||||||
|
* @function next - function to jump to next video in chromecast queue
|
||||||
|
*/
|
||||||
|
next: () => Promise<any>;
|
||||||
|
/**
|
||||||
|
* @function prev - function to jump to prev video in chromecast queue
|
||||||
|
*/
|
||||||
|
prev: () => Promise<any>;
|
||||||
|
/**
|
||||||
|
* @function to - function to jump to the time passed in seconds in chromecast playing video
|
||||||
|
* @param seconds - time in seconds to jump to
|
||||||
|
*/
|
||||||
|
to: (seconds: number) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMedia() {
|
||||||
|
const { session, castReceiver } = useContext(castContext);
|
||||||
|
const [media, setMedia] = useState<any>(null);
|
||||||
|
const [isMedia, setIsMedia] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session && isMedia) setIsMedia(false);
|
||||||
|
}, [session, isMedia]);
|
||||||
|
|
||||||
|
const playMedia = useCallback(
|
||||||
|
(src: string, autoplay?: boolean) =>
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
if (!castReceiver || !session)
|
||||||
|
return rej(new Error("An Error occurred"));
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const mediaInfo = new castReceiver.media.MediaInfo(src);
|
||||||
|
// @ts-ignore
|
||||||
|
const request = new castReceiver.media.LoadRequest(mediaInfo);
|
||||||
|
|
||||||
|
request.autoplay = autoplay || true;
|
||||||
|
session.loadMedia(
|
||||||
|
request,
|
||||||
|
(media: any) => {
|
||||||
|
setMedia(media);
|
||||||
|
setIsMedia(true);
|
||||||
|
res(media);
|
||||||
|
},
|
||||||
|
(err: any) => rej(err)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[castReceiver, session]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addMedia = useCallback(
|
||||||
|
async (src: string) => {
|
||||||
|
if (!castReceiver && !media) return;
|
||||||
|
// @ts-ignore
|
||||||
|
const mediaInfo = new castReceiver.media.MediaInfo(src);
|
||||||
|
// @ts-ignore
|
||||||
|
const queueItem = new castReceiver.media.QueueItem(mediaInfo);
|
||||||
|
await media.queueAppendItem(queueItem);
|
||||||
|
},
|
||||||
|
[media, castReceiver]
|
||||||
|
);
|
||||||
|
|
||||||
|
const play = useCallback(async () => {
|
||||||
|
if (!media) return;
|
||||||
|
await media.play();
|
||||||
|
}, [media]);
|
||||||
|
|
||||||
|
const pause = useCallback(async () => {
|
||||||
|
if (!media) return;
|
||||||
|
await media.pause();
|
||||||
|
}, [media]);
|
||||||
|
|
||||||
|
const prev = useCallback(async () => {
|
||||||
|
if (!media) return;
|
||||||
|
await media.queuePrev();
|
||||||
|
}, [media]);
|
||||||
|
|
||||||
|
const next = useCallback(async () => {
|
||||||
|
if (!media) return;
|
||||||
|
await media.queueNext();
|
||||||
|
}, [media]);
|
||||||
|
|
||||||
|
const to = useCallback(
|
||||||
|
async (seconds: number) => {
|
||||||
|
if (!media && !castReceiver) return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const seek = new castReceiver.media.SeekRequest();
|
||||||
|
|
||||||
|
seek.currentTime = seconds;
|
||||||
|
|
||||||
|
await media.seek(seek);
|
||||||
|
},
|
||||||
|
[media, castReceiver]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playMedia,
|
||||||
|
addMedia,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
isMedia,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
to,
|
||||||
|
} as Media;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useMedia;
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
const defaultTheme = require("tailwindcss/defaultTheme");
|
||||||
const windmill = require("@windmill/react-ui/config");
|
const windmill = require("@windmill/react-ui/config");
|
||||||
|
|
||||||
module.exports = {
|
const config = {
|
||||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
Rampart: ["Raleway", "sans-serif"],
|
Rampart: ["Raleway", "sans-serif"],
|
||||||
},
|
},
|
||||||
boxShadow: {
|
|
||||||
bottom:
|
|
||||||
"0 5px 6px -7px rgba(0, 0, 0, 0.6), 0 2px 4px -5px rgba(0, 0, 0, 0.06)",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...windmill(config),
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1760,6 +1760,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/helmet@^4.0.0":
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/helmet/-/helmet-4.0.0.tgz#af7af46de26abe368b85360769ae9938bfb2318a"
|
||||||
|
integrity sha512-ONIn/nSNQA57yRge3oaMQESef/6QhoeX7llWeDli0UZIfz8TQMkfNPTXA8VnnyeA1WUjG2pGqdjEIueYonMdfQ==
|
||||||
|
dependencies:
|
||||||
|
helmet "*"
|
||||||
|
|
||||||
"@types/hls.js@^1.0.0":
|
"@types/hls.js@^1.0.0":
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/hls.js/-/hls.js-1.0.0.tgz#47a03f97217461d279e4dc89481ddff86def5a36"
|
resolved "https://registry.yarnpkg.com/@types/hls.js/-/hls.js-1.0.0.tgz#47a03f97217461d279e4dc89481ddff86def5a36"
|
||||||
@@ -1868,6 +1875,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-helmet@^6.1.5":
|
||||||
|
version "6.1.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.1.5.tgz#35f89a6b1646ee2bc342a33a9a6c8777933f9083"
|
||||||
|
integrity sha512-/ICuy7OHZxR0YCAZLNg9r7I9aijWUWvxaPR6uTuyxe8tAj5RL4Sw1+R6NhXUtOsarkGYPmaHdBDvuXh2DIN/uA==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@^17.0.20":
|
"@types/react@*", "@types/react@^17.0.20":
|
||||||
version "17.0.43"
|
version "17.0.43"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.43.tgz#4adc142887dd4a2601ce730bc56c3436fdb07a55"
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.43.tgz#4adc142887dd4a2601ce730bc56c3436fdb07a55"
|
||||||
@@ -4545,6 +4559,11 @@ he@^1.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
||||||
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
|
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
|
||||||
|
|
||||||
|
helmet@*:
|
||||||
|
version "5.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/helmet/-/helmet-5.0.2.tgz#3264ec6bab96c82deaf65e3403c369424cb2366c"
|
||||||
|
integrity sha512-QWlwUZZ8BtlvwYVTSDTBChGf8EOcQ2LkGMnQJxSzD1mUu8CCjXJZq/BXP8eWw4kikRnzlhtYo3lCk0ucmYA3Vg==
|
||||||
|
|
||||||
history@^5.2.0:
|
history@^5.2.0:
|
||||||
version "5.3.0"
|
version "5.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
|
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
|
||||||
@@ -6960,7 +6979,7 @@ prompts@^2.0.1, prompts@^2.4.2:
|
|||||||
kleur "^3.0.3"
|
kleur "^3.0.3"
|
||||||
sisteransi "^1.0.5"
|
sisteransi "^1.0.5"
|
||||||
|
|
||||||
prop-types@^15.6.2, prop-types@^15.8.1:
|
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||||
version "15.8.1"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
@@ -7099,6 +7118,11 @@ react-error-overlay@^6.0.10:
|
|||||||
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6"
|
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6"
|
||||||
integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==
|
integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==
|
||||||
|
|
||||||
|
react-fast-compare@^3.1.1:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
|
||||||
|
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
|
||||||
|
|
||||||
react-focus-lock@2.4.1:
|
react-focus-lock@2.4.1:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.4.1.tgz#e842cc93da736b5c5d331799012544295cbcee4f"
|
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.4.1.tgz#e842cc93da736b5c5d331799012544295cbcee4f"
|
||||||
@@ -7111,6 +7135,16 @@ react-focus-lock@2.4.1:
|
|||||||
use-callback-ref "^1.2.1"
|
use-callback-ref "^1.2.1"
|
||||||
use-sidecar "^1.0.1"
|
use-sidecar "^1.0.1"
|
||||||
|
|
||||||
|
react-helmet@^6.1.0:
|
||||||
|
version "6.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726"
|
||||||
|
integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==
|
||||||
|
dependencies:
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
react-fast-compare "^3.1.1"
|
||||||
|
react-side-effect "^2.1.0"
|
||||||
|
|
||||||
react-icons@^4.3.1:
|
react-icons@^4.3.1:
|
||||||
version "4.3.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca"
|
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca"
|
||||||
@@ -7201,6 +7235,11 @@ react-scripts@5.0.0:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "^2.3.2"
|
fsevents "^2.3.2"
|
||||||
|
|
||||||
|
react-side-effect@^2.1.0:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3"
|
||||||
|
integrity sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ==
|
||||||
|
|
||||||
react-transition-group@4.4.1:
|
react-transition-group@4.4.1:
|
||||||
version "4.4.1"
|
version "4.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
|
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
|
||||||
|
|||||||
Reference in New Issue
Block a user