chore: added loading indicator to library items

chore: added default icons to library items/recently added cards when no images load
chore: fixed css for audio cover images being stretched in library
chore: added a testing subr route for testing conponents
fix: fix session cards not pulling up IP info when ip lookup is enabled
This commit is contained in:
CyferShepard
2024-07-09 20:36:55 +02:00
parent 6c849eb4c4
commit 3b8083a588
11 changed files with 731 additions and 262 deletions

View File

@@ -27,10 +27,9 @@ import ItemInfo from "./pages/components/item-info";
import ErrorPage from "./pages/components/general/error";
import About from "./pages/about";
import Testing from "./pages/testing";
import TestingRoutes from "./pages/testing";
import Activity from "./pages/activity";
import Statistics from "./pages/statistics";
import { t } from "i18next";
function App() {
const [setupState, setSetupState] = useState(0);
@@ -171,8 +170,8 @@ function App() {
<Route path="/libraries/item/:Id" element={<ItemInfo />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/activity" element={<Activity />} />
<Route path="/testing" element={<Testing />} />
<Route path="/about" element={<About />} />
<Route path="/testing/*" element={<TestingRoutes />} />
</Routes>
</main>
</div>

View File

@@ -284,7 +284,8 @@ export default function ActivityTable(props) {
enableGlobalFilter: false,
enableBottomToolbar: false,
enableRowSelection: (row) => row.original.Id,
enableSubRowSelection: true,
enableMultiRowSelection: true,
enableBatchRowSelection: true,
onRowSelectionChange: setRowSelection,
positionToolbarAlertBanner: "bottom",
renderTopToolbarCustomActions: () => {

View File

@@ -5,12 +5,30 @@ import { useParams } from "react-router-dom";
import ArchiveDrawerFillIcon from "remixicon-react/ArchiveDrawerFillIcon";
import "../../../css/lastplayed.css";
import { Trans } from "react-i18next";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
function MoreItemCards(props) {
const { Id } = useParams();
const [loaded, setLoaded] = useState(props.data.archived);
const [fallback, setFallback] = useState(false);
const SeriesIcon = <TvLineIcon size={"50%"} />;
const MovieIcon = <FilmLineIcon size={"50%"} />;
const MusicIcon = <FileMusicLineIcon size={"50%"} />;
const MixedIcon = <CheckboxMultipleBlankLineIcon size={"50%"} />;
const currentLibraryDefaultIcon =
props.data.Type === "Movie"
? MovieIcon
: props.data.Type === "Episode"
? SeriesIcon
: props.data.Type === "Audio"
? MusicIcon
: MixedIcon;
function formatFileSize(sizeInBytes) {
const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes
if (sizeInMB < 1000) {
@@ -27,7 +45,15 @@ function MoreItemCards(props) {
to={`/libraries/item/${props.data.Type === "Episode" ? props.data.EpisodeId : props.data.Id}`}
className="text-decoration-none"
>
<div className={props.data.Type === "Episode" ? "last-card-banner episode" : "last-card-banner"}>
<div
className={
(props.data.Type === "Episode"
? "last-card-banner episode"
: props.data.Type === "Audio"
? "last-card-banner audio"
: "last-card-banner") + " d-flex justify-content-center align-items-center"
}
>
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes != null) ||
(props.data.PrimaryImageHash && props.data.PrimaryImageHash != null)) &&
!loaded ? (
@@ -41,18 +67,24 @@ function MoreItemCards(props) {
{!props.data.archived ? (
fallback ? (
<img
src={`${"/proxy/Items/Images/Primary?id=" + Id + "&fillHeight=320&fillWidth=213&quality=50"}`}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { display: "block" } : { display: "none" }}
/>
Id == undefined ? (
currentLibraryDefaultIcon
) : (
<img
src={`${"/proxy/Items/Images/Primary?id=" + Id + "&fillHeight=320&fillWidth=213&quality=50"}`}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { display: "block" } : { display: "none" }}
/>
)
) : (
<img
src={`${
"/proxy/Items/Images/Primary?id=" +
(props.data.Type === "Episode" ? props.data.EpisodeId : props.data.Id) +
"&fillHeight=320&fillWidth=213&quality=50"
(props.data.Type === "Audio"
? "&fillHeight=300&fillWidth=300&quality=50"
: "&fillHeight=320&fillWidth=213&quality=50")
}`}
alt=""
onLoad={() => setLoaded(true)}

View File

@@ -1,12 +1,17 @@
import React, { useState } from "react";
import { useState } from "react";
import { Blurhash } from "react-blurhash";
import { Link } from "react-router-dom";
import "../../../css/lastplayed.css";
import { Trans } from "react-i18next";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
function RecentlyAddedCard(props) {
const [loaded, setLoaded] = useState(false);
const [fallback, setFallback] = useState(false);
const twelve_hr = JSON.parse(localStorage.getItem("12hr"));
const localization = localStorage.getItem("i18nextLng");
@@ -20,13 +25,34 @@ function RecentlyAddedCard(props) {
hour12: twelve_hr,
};
const SeriesIcon = <TvLineIcon size={"75%"} />;
const MovieIcon = <FilmLineIcon size={"75%"} />;
const MusicIcon = <FileMusicLineIcon size={"75%"} />;
const MixedIcon = <CheckboxMultipleBlankLineIcon size={"75%"} />;
const currentLibraryDefaultIcon =
props.data.Type === "Movie"
? MovieIcon
: props.data.Type === "Episode"
? SeriesIcon
: props.data.Type === "Audio"
? MusicIcon
: MixedIcon;
return (
<div className="last-card">
<Link to={`/libraries/item/${(props.data.NewEpisodeCount != undefined ? props.data.SeasonId : props.data.EpisodeId) ?? props.data.Id}`}>
<div className="last-card-banner">
{loaded ? null : props.data.PrimaryImageHash ? (
<Blurhash hash={props.data.PrimaryImageHash} width={"100%"} height={"100%"} className="rounded-3 overflow-hidden" />
) : null}
<Link
to={`/libraries/item/${
(props.data.NewEpisodeCount != undefined ? props.data.SeasonId : props.data.EpisodeId) ?? props.data.Id
}`}
>
<div
className={
(props.data.Type === "Audio" ? "last-card-banner audio" : "last-card-banner") +
" d-flex justify-content-center align-items-center"
}
>
{fallback ? currentLibraryDefaultIcon : null}
<img
src={`${
"/proxy/Items/Images/Primary?id=" +
@@ -35,6 +61,7 @@ function RecentlyAddedCard(props) {
}`}
alt=""
onLoad={() => setLoaded(true)}
onError={() => setFallback(true)}
style={loaded ? {} : { display: "none" }}
/>
</div>

View File

@@ -11,6 +11,7 @@ import "../../css/library/media-items.css";
import "../../css/width_breakpoint_css.css";
import "../../css/radius_breakpoint_css.css";
import { Trans } from "react-i18next";
import Loading from "../general/loading";
function LibraryItems(props) {
const [data, setData] = useState();
@@ -92,7 +93,7 @@ function LibraryItems(props) {
}
if (!data || !config) {
return <></>;
return <Loading />;
}
return (

View File

@@ -1,20 +1,19 @@
/* eslint-disable react/prop-types */
import React, {useState,useEffect} from "react";
import { Link } from 'react-router-dom';
import Card from 'react-bootstrap/Card';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Card from "react-bootstrap/Card";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import Container from "react-bootstrap/Container";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
import PlayFillIcon from "remixicon-react/PlayFillIcon";
import PauseFillIcon from "remixicon-react/PauseFillIcon";
import { clientData } from "../../../lib/devices";
import Tooltip from "@mui/material/Tooltip";
import IpInfoModal from '../ip-info';
import axios from 'axios';
import Tooltip from "@mui/material/Tooltip";
import IpInfoModal from "../ip-info";
import { Trans } from "react-i18next";
function ticksToTimeString(ticks) {
// Convert ticks to seconds
@@ -24,57 +23,59 @@ function ticksToTimeString(ticks) {
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
// Format the time string as hh:MM:ss
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
const timeString = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
.padStart(2, "0")}`;
return timeString;
}
function getETAFromTicks(ticks) {
// Get current date
const currentDate = Date.now();
// Calculate ETA
const etaMillis = currentDate + ticks/10000;
const etaMillis = currentDate + ticks / 10000;
const eta = new Date(etaMillis);
// Return formated string in user locale
return eta.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
return eta.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function convertBitrate(bitrate) {
if(!bitrate)
{
return 'N/A';
if (!bitrate) {
return "N/A";
}
const kbps = (bitrate / 1000).toFixed(1);
const mbps = (bitrate / 1000000).toFixed(1);
if (kbps >= 1000) {
return mbps+' Mbps';
return mbps + " Mbps";
} else {
return kbps+' Kbps';
return kbps + " Kbps";
}
}
function SessionCard(props) {
const cardStyle = {
backgroundImage: `url(proxy/Items/Images/Backdrop?id=${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height:'100%',
backgroundSize: 'cover',
backgroundImage: `url(proxy/Items/Images/Backdrop?id=${
props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.SeriesId
: props.data.session.NowPlayingItem.Id
}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height: "100%",
backgroundSize: "cover",
};
const cardBgStyle = {
backdropFilter: 'blur(5px)',
backgroundColor: 'rgb(0, 0, 0, 0.6)',
height:'100%',
backdropFilter: "blur(5px)",
backgroundColor: "rgb(0, 0, 0, 0.6)",
height: "100%",
};
const ipv4Regex = new RegExp(/\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/);
const ipv4Regex = new RegExp(
/\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
);
const [ipModalVisible, setIPModalVisible] = React.useState(false);
const [ipAddressLookup, setIPAddressLookup] = React.useState();
@@ -87,7 +88,6 @@ function SessionCard(props) {
return false;
};
function showIPDataModal(ipAddress) {
ipv4Regex.lastIndex = 0;
setIPAddressLookup(ipAddress);
@@ -98,188 +98,211 @@ function SessionCard(props) {
setIPModalVisible(true);
}
return (
<Card className="stat-card" style={cardStyle}>
<IpInfoModal show={ipModalVisible} onHide={() => setIPModalVisible(false)} ipAddress={ipAddressLookup} />
<div style={cardBgStyle} className="rounded-top">
<Row className="h-100">
<Col className="d-none d-lg-block stat-card-banner">
<Card.Img
variant="top"
className={props.data.session.NowPlayingItem.Type==='Audio' ? (
"stat-card-image-audio rounded-0 rounded-start"
) : (
"stat-card-image rounded-0 rounded-start"
)}
src={"/proxy/Items/Images/Primary?id=" + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + "&fillHeight=320&fillWidth=213&quality=50"}
/>
</Col>
<Col className="w-100 h-100">
<Card.Body className="w-100 h-100 p-1 pb-2" >
<Container className="h-100 d-flex flex-column justify-content-between g-0">
<Row className="d-flex justify-content-end" style={{fontSize: "smaller"}}>
<div style={cardBgStyle} className="rounded-top">
<Row className="h-100">
<Col className="d-none d-lg-block stat-card-banner">
<Card.Img
variant="top"
className={
props.data.session.NowPlayingItem.Type === "Audio"
? "stat-card-image-audio rounded-0 rounded-start"
: "stat-card-image rounded-0 rounded-start"
}
src={
"/proxy/Items/Images/Primary?id=" +
(props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.SeriesId
: props.data.session.NowPlayingItem.Id) +
"&fillHeight=320&fillWidth=213&quality=50"
}
/>
</Col>
<Col className="w-100 h-100">
<Card.Body className="w-100 h-100 p-1 pb-2">
<Container className="h-100 d-flex flex-column justify-content-between g-0">
<Row className="d-flex justify-content-end" style={{ fontSize: "smaller" }}>
<Col className="col-10">
<Row>
<Col className="col-auto ellipse">{props.data.session.DeviceName +" - "+props.data.session.Client + " " + props.data.session.ApplicationVersion}</Col>
<Row>
<Col className="col-auto ellipse">
{props.data.session.DeviceName +
" - " +
props.data.session.Client +
" " +
props.data.session.ApplicationVersion}
</Col>
</Row>
<Row className="d-flex flex-column flex-md-row">
<Col className="col-auto ellipse">{props.data.session.PlayState.PlayMethod +(props.data.session.NowPlayingItem.MediaStreams ? ' ( '+props.data.session.NowPlayingItem.MediaStreams.find(stream => stream.Type==='Video')?.Codec.toUpperCase()+(props.data.session.TranscodingInfo? ' - '+props.data.session.TranscodingInfo.VideoCodec.toUpperCase() : '')+' - '+convertBitrate(props.data.session.TranscodingInfo ? props.data.session.TranscodingInfo.Bitrate :props.data.session.NowPlayingItem.MediaStreams.find(stream => stream.Type==='Video')?.BitRate)+' )':'')}</Col>
<Row className="d-flex flex-column flex-md-row">
<Col className="col-auto ellipse">
{props.data.session.PlayState.PlayMethod +
(props.data.session.NowPlayingItem.MediaStreams
? " ( " +
props.data.session.NowPlayingItem.MediaStreams.find(
(stream) => stream.Type === "Video"
)?.Codec.toUpperCase() +
(props.data.session.TranscodingInfo
? " - " + props.data.session.TranscodingInfo.VideoCodec.toUpperCase()
: "") +
" - " +
convertBitrate(
props.data.session.TranscodingInfo
? props.data.session.TranscodingInfo.Bitrate
: props.data.session.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video")
?.BitRate
) +
" )"
: "")}
</Col>
<Col className="col-auto ellipse">
<Tooltip title={props.data.session.NowPlayingItem.SubtitleStream}>
<span>
{props.data.session.NowPlayingItem.SubtitleStream}
</span>
<span>{props.data.session.NowPlayingItem.SubtitleStream}</span>
</Tooltip>
</Col>
</Col>
</Row>
<Row>
<Col className="col-auto ellipse">
{isRemoteSession(props.data.session.RemoteEndPoint) && (import.meta.env.JS_GEOLITE_ACCOUNT_ID && import.meta.env.JS_GEOLITE_LICENSE_KEY) ?
<Card.Text>
</Card.Text>
:
{isRemoteSession(props.data.session.RemoteEndPoint) &&
import.meta.env.JS_GEOLITE_ACCOUNT_ID &&
import.meta.env.JS_GEOLITE_LICENSE_KEY ? (
<Link
className="text-decoration-none text-white"
onClick={() => showIPDataModal(props.data.session.RemoteEndPoint)}
>
<Trans i18nKey="ACTIVITY_TABLE.IP_ADDRESS" />: {props.data.session.RemoteEndPoint}
</Link>
) : (
<span>
IP Address: {props.data.session.RemoteEndPoint}
<Trans i18nKey="ACTIVITY_TABLE.IP_ADDRESS" />: {props.data.session.RemoteEndPoint}
</span>
}
)}
</Col>
</Row>
</Col>
<Col className="col-2 d-flex justify-content-center">
<img
className="card-device-image"
src={
"/proxy/web/assets/img/devices/?devicename=" +
(props.data.session.Client.toLowerCase() === "jellyfin mobile (ios)" && props.data.session.DeviceName.toLowerCase() === "iphone" ?
"apple"
:
props.data.session.Client.toLowerCase().includes("web") ?
( clientData.find(item => props.data.session.DeviceName.toLowerCase().includes(item)) || "other")
:
( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other")
)
}
alt=""
/>
</Col>
</Row>
<Row className="p-0 m-0">
{props.data.session.NowPlayingItem.Type==='Episode' ?
<Row className="d-flex flex-row justify-content-between p-0">
<Card.Text>
<Link to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`} target="_blank" className="item-name">
{props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)}
</Link>
</Card.Text>
</Row>
:
<></>
}
<Row className="d-flex flex-row justify-content-between p-0 m-0" >
{props.data.session.NowPlayingItem.Type==='Episode' ?
<Col className="col-auto p-0">
<Card.Text >
{'S'+props.data.session.NowPlayingItem.ParentIndexNumber +' - E'+ props.data.session.NowPlayingItem.IndexNumber}
</Card.Text>
</Col>
:
<Col className="p-0">
<Card.Text>
<Link to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`} target="_blank" className="item-name">
{props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)}
</Link>
</Card.Text>
</Col>
className="card-device-image"
src={
"/proxy/web/assets/img/devices/?devicename=" +
(props.data.session.Client.toLowerCase() === "jellyfin mobile (ios)" &&
props.data.session.DeviceName.toLowerCase() === "iphone"
? "apple"
: props.data.session.Client.toLowerCase().includes("web")
? clientData.find((item) => props.data.session.DeviceName.toLowerCase().includes(item)) || "other"
: clientData.find((item) => props.data.session.Client.toLowerCase().includes(item)) || "other")
}
<Col className="d-flex flex-row justify-content-end text-end col-auto">
{props.data.session.UserPrimaryImageTag !== undefined ? (
<img
className="session-card-user-image"
src={
"/proxy/Users/Images/Primary?id=" +
props.data.session.UserId +
"&quality=50"
}
alt=""
/>
) : (
<AccountCircleFillIcon className="session-card-user-image"/>
)}
<Card.Text >
<Tooltip title={props.data.session.UserName} >
<Link to={`/users/${props.data.session.UserId}`} className="item-name" style={{maxWidth:'15ch'}}>{props.data.session.UserName}</Link>
</Tooltip>
</Card.Text>
alt=""
/>
</Col>
</Row>
<Row className="p-0 m-0">
<Col className="col-auto p-0">
{props.data.session.NowPlayingItem.Type === "Episode" ? (
<Row className="d-flex flex-row justify-content-between p-0">
<Card.Text>
<Link
to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`}
target="_blank"
className="item-name"
>
{props.data.session.NowPlayingItem.SeriesName
? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name
: props.data.session.NowPlayingItem.Name}
</Link>
</Card.Text>
</Row>
) : (
<></>
)}
<Row className="d-flex flex-row justify-content-between p-0 m-0">
{props.data.session.NowPlayingItem.Type === "Episode" ? (
<Col className="col-auto p-0">
<Card.Text>
{"S" +
props.data.session.NowPlayingItem.ParentIndexNumber +
" - E" +
props.data.session.NowPlayingItem.IndexNumber}
</Card.Text>
</Col>
) : (
<Col className="p-0">
<Card.Text>
<Link
to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`}
target="_blank"
className="item-name"
>
{props.data.session.NowPlayingItem.SeriesName
? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name
: props.data.session.NowPlayingItem.Name}
</Link>
</Card.Text>
</Col>
)}
{props.data.session.PlayState.IsPaused ?
<PauseFillIcon />
:
<PlayFillIcon />
}
<Col className="d-flex flex-row justify-content-end text-end col-auto">
{props.data.session.UserPrimaryImageTag !== undefined ? (
<img
className="session-card-user-image"
src={"/proxy/Users/Images/Primary?id=" + props.data.session.UserId + "&quality=50"}
alt=""
/>
) : (
<AccountCircleFillIcon className="session-card-user-image" />
)}
<Card.Text>
<Tooltip title={props.data.session.UserName}>
<Link to={`/users/${props.data.session.UserId}`} className="item-name" style={{ maxWidth: "15ch" }}>
{props.data.session.UserName}
</Link>
</Tooltip>
</Card.Text>
</Col>
</Row>
</Col>
<Row className="p-0 m-0">
<Col className="col-auto p-0">
{props.data.session.PlayState.IsPaused ? <PauseFillIcon /> : <PlayFillIcon />}
</Col>
<Col>
<Card.Text className="text-end">
<Tooltip title={`Ends at ${getETAFromTicks(props.data.session.NowPlayingItem.RunTimeTicks - props.data.session.PlayState.PositionTicks)}`}>
<span>
{ticksToTimeString(props.data.session.PlayState.PositionTicks)}/
{ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)}
</span>
</Tooltip>
</Card.Text>
</Col>
<Col>
<Card.Text className="text-end">
<Tooltip
title={`Ends at ${getETAFromTicks(
props.data.session.NowPlayingItem.RunTimeTicks - props.data.session.PlayState.PositionTicks
)}`}
>
<span>
{ticksToTimeString(props.data.session.PlayState.PositionTicks)}/
{ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)}
</span>
</Tooltip>
</Card.Text>
</Col>
</Row>
</Row>
</Row>
</Container>
</Card.Body>
</Col>
</Row>
<Row>
<Col>
<div className="progress-bar">
<div
className="progress-custom"
style={{
width: `${
(props.data.session.PlayState.PositionTicks /
props.data.session.NowPlayingItem.RunTimeTicks) *
100
}%`,
}}
></div>
</Card.Body>
</Col>
</Row>
<Row>
<Col>
<div className="progress-bar">
<div
className="progress-custom"
style={{
width: `${
(props.data.session.PlayState.PositionTicks / props.data.session.NowPlayingItem.RunTimeTicks) * 100
}%`,
}}
></div>
</div>
</Col>
</Row>
</div>
</Col>
</Row>
</div>
</Card>
</Card>
);
}

View File

@@ -47,6 +47,10 @@
width: 220px !important;
}
.audio {
height: 150px !important;
}
.last-card-banner {
width: 150px;
height: 220px;

View File

@@ -0,0 +1,317 @@
/* eslint-disable react/prop-types */
import React, { useState } from "react";
import { Link } from "react-router-dom";
import Card from "react-bootstrap/Card";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import Container from "react-bootstrap/Container";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
import PlayFillIcon from "remixicon-react/PlayFillIcon";
import PauseFillIcon from "remixicon-react/PauseFillIcon";
import { clientData } from "../../lib/devices";
import Tooltip from "@mui/material/Tooltip";
import IpInfoModal from "../components/ip-info";
import "./sessionCard.css";
function ticksToTimeString(ticks) {
// Convert ticks to seconds
const seconds = Math.floor(ticks / 10000000);
// Calculate hours, minutes, and remaining seconds
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
// Format the time string as hh:MM:ss
const timeString = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}`;
return timeString;
}
function getETAFromTicks(ticks) {
// Get current date
const currentDate = Date.now();
// Calculate ETA
const etaMillis = currentDate + ticks / 10000;
const eta = new Date(etaMillis);
// Return formated string in user locale
return eta.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function convertBitrate(bitrate) {
if (!bitrate) {
return "N/A";
}
const kbps = (bitrate / 1000).toFixed(1);
const mbps = (bitrate / 1000000).toFixed(1);
if (kbps >= 1000) {
return mbps + " Mbps";
} else {
return kbps + " Kbps";
}
}
function SessionCard(props) {
const cardStyle = {
backgroundImage: `url(proxy/Items/Images/Backdrop?id=${
props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.SeriesId
: props.data.session.NowPlayingItem.Id
}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height: "100%",
backgroundSize: "cover",
};
const cardBgStyle = {
backdropFilter: "blur(5px)",
backgroundColor: "rgb(0, 0, 0, 0.6)",
height: "100%",
};
const ipv4Regex = new RegExp(
/\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
);
const [ipModalVisible, setIPModalVisible] = React.useState(false);
const [ipAddressLookup, setIPAddressLookup] = React.useState();
const [isJsonVisible, setIsJsonVisible] = useState(false);
const toggleJsonVisibility = () => {
setIsJsonVisible(!isJsonVisible);
};
const isRemoteSession = (ipAddress) => {
ipv4Regex.lastIndex = 0;
if (ipv4Regex.test(ipAddress ?? ipAddressLookup)) {
return true;
}
return false;
};
function showIPDataModal(ipAddress) {
ipv4Regex.lastIndex = 0;
setIPAddressLookup(ipAddress);
if (!isRemoteSession) {
return;
}
setIPModalVisible(true);
}
return (
<Card className="stat-card" style={cardStyle}>
<IpInfoModal show={ipModalVisible} onHide={() => setIPModalVisible(false)} ipAddress={ipAddressLookup} />
<div style={cardBgStyle} className="rounded-top">
<Row className="h-100">
<Col className="d-none d-lg-block stat-card-banner">
<Card.Img
variant="top"
className={
props.data.session.NowPlayingItem.Type === "Audio"
? "stat-card-image-audio rounded-0 rounded-start"
: "stat-card-image rounded-0 rounded-start"
}
src={
"/proxy/Items/Images/Primary?id=" +
(props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.SeriesId
: props.data.session.NowPlayingItem.Id) +
"&fillHeight=320&fillWidth=213&quality=50"
}
/>
</Col>
<Col className="w-100 h-100">
<Card.Body className="w-100 h-100 p-1 pb-2">
<Container className="h-100 d-flex flex-column justify-content-between g-0">
<Row className="d-flex justify-content-end" style={{ fontSize: "smaller" }}>
<Col className="col-10">
<Row>
<Col className="col-auto ellipse">
{props.data.session.DeviceName +
" - " +
props.data.session.Client +
" " +
props.data.session.ApplicationVersion}
</Col>
</Row>
<Row className="d-flex flex-column flex-md-row">
<Col className="col-auto ellipse">
{props.data.session.PlayState.PlayMethod +
(props.data.session.NowPlayingItem.MediaStreams
? " ( " +
props.data.session.NowPlayingItem.MediaStreams.find(
(stream) => stream.Type === "Video"
)?.Codec.toUpperCase() +
(props.data.session.TranscodingInfo
? " - " + props.data.session.TranscodingInfo.VideoCodec.toUpperCase()
: "") +
" - " +
convertBitrate(
props.data.session.TranscodingInfo
? props.data.session.TranscodingInfo.Bitrate
: props.data.session.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video")
?.BitRate
) +
" )"
: "")}
</Col>
<Col className="col-auto ellipse">
<Tooltip title={props.data.session.NowPlayingItem.SubtitleStream}>
<span>{props.data.session.NowPlayingItem.SubtitleStream}</span>
</Tooltip>
</Col>
</Row>
<Row>
<Col className="col-auto ellipse">
{isRemoteSession(props.data.session.RemoteEndPoint) &&
import.meta.env.JS_GEOLITE_ACCOUNT_ID &&
import.meta.env.JS_GEOLITE_LICENSE_KEY ? (
<Card.Text></Card.Text>
) : (
<span>IP Address: {props.data.session.RemoteEndPoint}</span>
)}
</Col>
</Row>
</Col>
<Col className="col-2 d-flex justify-content-center">
<img
className="card-device-image"
src={
"/proxy/web/assets/img/devices/?devicename=" +
(props.data.session.Client.toLowerCase() === "jellyfin mobile (ios)" &&
props.data.session.DeviceName.toLowerCase() === "iphone"
? "apple"
: props.data.session.Client.toLowerCase().includes("web")
? clientData.find((item) => props.data.session.DeviceName.toLowerCase().includes(item)) || "other"
: clientData.find((item) => props.data.session.Client.toLowerCase().includes(item)) || "other")
}
alt=""
/>
</Col>
</Row>
<Row className="p-0 m-0">
{props.data.session.NowPlayingItem.Type === "Episode" ? (
<Row className="d-flex flex-row justify-content-between p-0">
<Card.Text>
<Link
to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`}
target="_blank"
className="item-name"
>
{props.data.session.NowPlayingItem.SeriesName
? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name
: props.data.session.NowPlayingItem.Name}
</Link>
</Card.Text>
</Row>
) : (
<></>
)}
<Row className="d-flex flex-row justify-content-between p-0 m-0">
{props.data.session.NowPlayingItem.Type === "Episode" ? (
<Col className="col-auto p-0">
<Card.Text>
{"S" +
props.data.session.NowPlayingItem.ParentIndexNumber +
" - E" +
props.data.session.NowPlayingItem.IndexNumber}
</Card.Text>
</Col>
) : (
<Col className="p-0">
<Card.Text>
<Link
to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`}
target="_blank"
className="item-name"
>
{props.data.session.NowPlayingItem.SeriesName
? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name
: props.data.session.NowPlayingItem.Name}
</Link>
</Card.Text>
</Col>
)}
<Col className="d-flex flex-row justify-content-end text-end col-auto">
{props.data.session.UserPrimaryImageTag !== undefined ? (
<img
className="session-card-user-image"
src={"/proxy/Users/Images/Primary?id=" + props.data.session.UserId + "&quality=50"}
alt=""
/>
) : (
<AccountCircleFillIcon className="session-card-user-image" />
)}
<Card.Text>
<Tooltip title={props.data.session.UserName}>
<Link to={`/users/${props.data.session.UserId}`} className="item-name" style={{ maxWidth: "15ch" }}>
{props.data.session.UserName}
</Link>
</Tooltip>
</Card.Text>
</Col>
</Row>
<Row className="p-0 m-0">
<Col className="col-auto p-0">
{props.data.session.PlayState.IsPaused ? <PauseFillIcon /> : <PlayFillIcon />}
</Col>
<Col>
<Card.Text className="text-end">
<Tooltip
title={`Ends at ${getETAFromTicks(
props.data.session.NowPlayingItem.RunTimeTicks - props.data.session.PlayState.PositionTicks
)}`}
>
<span>
{ticksToTimeString(props.data.session.PlayState.PositionTicks)}/
{ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)}
</span>
</Tooltip>
</Card.Text>
</Col>
</Row>
</Row>
</Container>
</Card.Body>
</Col>
</Row>
<Row>
<Col>
<div className="progress-bar">
<div
className="progress-custom"
style={{
width: `${
(props.data.session.PlayState.PositionTicks / props.data.session.NowPlayingItem.RunTimeTicks) * 100
}%`,
}}
></div>
</div>
</Col>
</Row>
</div>
<div>
<button className="btn btn-primary w-100 p-0 m-0" onClick={toggleJsonVisibility}>
Toggle JSON View
</button>
{isJsonVisible && (
<div className="json-data-container">
<pre>{JSON.stringify(props, null, 2)}</pre>
</div>
)}
</div>
</Card>
);
}
export default SessionCard;

View File

@@ -0,0 +1,6 @@
.json-data-container {
max-height: 300px;
overflow-y: auto;
background-color: black;
border-radius: 0px 0px 8px 8px;
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from "react";
import Config from "../../lib/config";
import "../css/sessions.css";
import ErrorBoundary from "../components/general/ErrorBoundary";
import SessionCard from "./session-card";
import Loading from "../components/general/loading";
import { Trans } from "react-i18next";
import socket from "../../socket";
function Sessions() {
const [data, setData] = useState();
const [config, setConfig] = useState();
useEffect(() => {
socket.on("sessions", (data) => {
if (typeof data === "object" && Array.isArray(data)) {
let toSet = data.filter((row) => row.NowPlayingItem !== undefined);
toSet.forEach((s) => {
handleLiveTV(s);
s.NowPlayingItem.SubtitleStream = getSubtitleStream(s);
});
setData(toSet);
}
});
return () => {
socket.off("sessions");
};
}, [config]);
const handleLiveTV = (row) => {
let nowPlaying = row.NowPlayingItem;
if (!nowPlaying.RunTimeTicks && nowPlaying?.CurrentProgram) {
nowPlaying.RunTimeTicks = 0;
nowPlaying.Name = `${nowPlaying.Name}: ${nowPlaying.CurrentProgram.Name}`;
}
};
const getSubtitleStream = (row) => {
let result = "";
if (!row.PlayState) {
return result;
}
let subStreamIndex = row.PlayState.SubtitleStreamIndex;
if (subStreamIndex === undefined || subStreamIndex === -1) {
return result;
}
if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length) {
result = `Subtitles: ${row.NowPlayingItem.MediaStreams[subStreamIndex].DisplayTitle}`;
}
return result;
};
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
if (!config) {
fetchConfig();
}
}, [config]);
if (!data && !config) {
return <Loading />;
}
if ((!data && config) || data.length === 0) {
return (
<div>
<h1 className="my-3">
<Trans i18nKey="HOME_PAGE.SESSIONS" />
</h1>
<div style={{ color: "grey", fontSize: "0.8em", fontStyle: "italic" }}>
<Trans i18nKey="SESSIONS.NO_SESSIONS" />
</div>
</div>
);
}
return (
<div>
<h1 className="my-3">
<Trans i18nKey="HOME_PAGE.SESSIONS" />
</h1>
<div className="sessions-container">
{data &&
data.length > 0 &&
data
.sort((a, b) => a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0")))
.map((session) => (
<ErrorBoundary key={session.Id}>
<SessionCard data={{ session: session, base_url: config.base_url }} />
</ErrorBoundary>
))}
</div>
</div>
);
}
export default Sessions;

View File

@@ -1,65 +1,12 @@
import { toast } from 'react-toastify';
import './css/library/libraries.css';
// import LibraryOverView from './components/libraryOverview';
// import HomeStatisticCards from './components/HomeStatisticCards';
// import Sessions from './components/sessions/sessions';
import LibrarySelector from './library_selector';
import { Button } from '@mui/material';
function Testing() {
// async function getToken(username,password) {
// const response = await fetch('http://localhost:3003/login', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({
// username: username,
// password: password,
// }),
// });
// const data = await response.json();
// return data.token;
// }
// // Make a GET request with JWT authentication
// async function getDataWithAuth() {
// try {
// const token = await getToken('test','pass'); // a function to get the JWT token
// // console.log(token);
// localStorage.setItem('token', token);
// } catch (error) {
// console.error(error);
// }
// }
// getDataWithAuth();
import { Routes, Route } from "react-router-dom";
import Sessions from "./debugTools/sessions";
const TestingRoutes = () => {
return (
<div className='Activity'>
<LibrarySelector/>
<Button variant="contained" color="primary" onClick={()=>toast.info('Test Info', {autoClose: 15000,})}>Test Toast Info</Button>
<Button variant="contained" color="success" onClick={()=>toast.success('Test Success', {autoClose: 15000,})}>Test Toast Success</Button>
<Button variant="contained" color="error" onClick={()=>toast.error('Test Error', {autoClose: 15000,})}>Test Toast Error</Button>
<Button variant="contained" color="warning" onClick={()=>toast.warn('Test Warn', {autoClose: 15000,})}>Test Toast Warn</Button>
</div>
<Routes>
<Route path="/sessions" element={<Sessions />} />
</Routes>
);
}
};
export default Testing;
export default TestingRoutes;