Merge branch 'development' into feat/manage-streaming-urls

This commit is contained in:
Timothy Z. 2024-11-28 17:39:09 +02:00
commit e7099767c4
53 changed files with 1629 additions and 156 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

14
package-lock.json generated
View file

@ -51,6 +51,7 @@
"@stylistic/eslint-plugin-jsx": "^2.9.0",
"@types/hat": "^0.0.4",
"@types/react": "^18.2.9",
"@types/react-dom": "^18.3.0",
"babel-loader": "8.2.3",
"clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "9.0.1",
@ -3142,6 +3143,7 @@
"version": "7.24.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz",
"integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -3152,7 +3154,8 @@
"node_modules/@stremio/stremio-core-web/node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/@stremio/stremio-icons": {
"version": "5.4.0",
@ -3556,6 +3559,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.17.1",
"dev": true,

View file

@ -55,6 +55,7 @@
"@stylistic/eslint-plugin-jsx": "^2.9.0",
"@types/hat": "^0.0.4",
"@types/react": "^18.2.9",
"@types/react-dom": "^18.3.0",
"babel-loader": "8.2.3",
"clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "9.0.1",

View file

@ -23,6 +23,10 @@ const routerViewsConfig = [
...routesRegexp.library,
component: routes.Library
},
{
...routesRegexp.calendar,
component: routes.Calendar
},
{
...routesRegexp.continuewatching,
component: routes.Library

View file

@ -91,6 +91,7 @@ html {
min-height: 480px;
font-family: 'PlusJakartaSans', 'sans-serif';
overflow: auto;
overscroll-behavior: none;
body {
width: 100%;

View file

@ -0,0 +1,102 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.bottom-sheet {
z-index: 99;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
.backdrop {
z-index: 0;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: var(--primary-background-color);
opacity: 0.8;
transition: opacity 0.1s ease-out;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.container {
z-index: 1;
position: absolute;
bottom: 0;
max-height: calc(100% - var(--horizontal-nav-bar-size));
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-bottom: 1rem;
border-radius: 2rem 2rem 0 0;
background-color: var(--modal-background-color);
box-shadow: var(--outer-glow);
overflow: hidden;
&:not(.dragging) {
transition: transform 0.1s ease-out;
}
.heading {
position: relative;
.handle {
position: relative;
height: 2.5rem;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
&::after {
content: "";
height: 0.3rem;
width: 3rem;
border-radius: 1rem;
background-color: var(--primary-foreground-color);
opacity: 0.3;
}
}
.title {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-left: 1.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--primary-foreground-color);
}
}
.content {
position: relative;
overflow-y: auto;
}
}
}
@media only screen and (min-width: @xsmall) {
.bottom-sheet {
display: none;
}
}
@media only screen and (orientation: landscape) {
.bottom-sheet {
.container {
max-width: 90%;
}
}
}

View file

@ -0,0 +1,87 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import useBinaryState from 'stremio/common/useBinaryState';
import styles from './BottomSheet.less';
const CLOSE_THRESHOLD = 100;
type Props = {
children: JSX.Element,
title: string,
show?: boolean,
onClose: () => void,
};
const BottomSheet = ({ children, title, show, onClose }: Props) => {
const containerRef = useRef<HTMLDivElement>(null);
const [startOffset, setStartOffset] = useState(0);
const [offset, setOffset] = useState(0);
const [opened, open, close] = useBinaryState();
const containerStyle = useMemo(() => ({
transform: `translateY(${offset}px)`
}), [offset]);
const containerHeight = () => containerRef.current?.offsetHeight ?? 0;
const onCloseRequest = () => setOffset(containerHeight());
const onTouchStart = ({ touches }: React.TouchEvent<HTMLDivElement>) => {
const { clientY } = touches[0];
setStartOffset(clientY);
};
const onTouchMove = useCallback(({ touches }: React.TouchEvent<HTMLDivElement>) => {
const { clientY } = touches[0];
setOffset(Math.max(0, clientY - startOffset));
}, [startOffset]);
const onTouchEnd = () => {
setOffset((offset) => offset > CLOSE_THRESHOLD ? containerHeight() : 0);
setStartOffset(0);
};
const onTransitionEnd = useCallback(() => {
(offset === containerHeight()) && close();
}, [offset]);
useEffect(() => {
setOffset(0);
show ? open() : close();
}, [show]);
useEffect(() => {
!opened && onClose();
}, [opened]);
return opened && createPortal((
<div className={styles['bottom-sheet']}>
<div className={styles['backdrop']} onClick={onCloseRequest} />
<div
ref={containerRef}
className={classNames(styles['container'], { [styles['dragging']]: startOffset }, 'animation-slide-up')}
style={containerStyle}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTransitionEnd={onTransitionEnd}
>
<div className={styles['heading']}>
<div className={styles['handle']} />
<div className={styles['title']}>
{title}
</div>
</div>
<div className={styles['content']} onClick={onCloseRequest}>
{children}
</div>
</div>
</div>
), document.body);
};
export default BottomSheet;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2024 Smart code 203358507
import BottomSheet from './BottomSheet';
export default BottomSheet;

View file

@ -20,14 +20,16 @@
background-color: transparent;
user-select: none;
overflow: hidden;
opacity: 0.6;
&:hover {
background-color: var(--overlay-color);
transition: background-color 0.1s ease-out;
opacity: 1;
}
&.active {
font-weight: 700;
opacity: 1;
background-color: var(--quaternary-accent-color);
transition: background-color 0.1s ease-in;
}

View file

@ -1,7 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
@mask-width: 10%;
.chips {
position: relative;
width: 100%;
@ -9,17 +7,4 @@
align-items: center;
justify-content: flex-start;
gap: 1rem;
overflow-x: auto;
&.left {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) calc(100% - @mask-width), rgba(0, 0, 0, 0) 100%);
}
&.right {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) @mask-width);
}
&.center {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) @mask-width, rgba(0, 0, 0, 1) calc(100% - @mask-width), rgba(0, 0, 0, 0) 100%);
}
}

View file

@ -1,7 +1,7 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { memo, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import React, { memo } from 'react';
import HorizontalScroll from '../HorizontalScroll';
import Chip from './Chip';
import styles from './Chips.less';
@ -16,28 +16,9 @@ type Props = {
onSelect: (value: string) => {},
};
const SCROLL_THRESHOLD = 1;
const Chips = memo(({ options, selected, onSelect }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [scrollPosition, setScrollPosition] = useState('left');
useEffect(() => {
const onScroll = ({ target }: Event) => {
const { scrollLeft, scrollWidth, offsetWidth} = target as HTMLDivElement;
const position =
(scrollLeft - SCROLL_THRESHOLD) <= 0 ? 'left' :
(scrollLeft + offsetWidth + SCROLL_THRESHOLD) >= scrollWidth ? 'right' :
'center';
setScrollPosition(position);
};
ref.current?.addEventListener('scroll', onScroll);
return () => ref.current?.removeEventListener('scroll', onScroll);
}, []);
return (
<div ref={ref} className={classNames(styles['chips'], [styles[scrollPosition]])}>
<HorizontalScroll className={styles['chips']}>
{
options.map(({ label, value }) => (
<Chip
@ -49,7 +30,7 @@ const Chips = memo(({ options, selected, onSelect }: Props) => {
/>
))
}
</div>
</HorizontalScroll>
);
});

View file

@ -0,0 +1,20 @@
// Copyright (C) 2017-2024 Smart code 203358507
@mask-width: 10%;
.horizontal-scroll {
position: relative;
overflow-x: auto;
&.left {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) calc(100% - @mask-width), rgba(0, 0, 0, 0) 100%);
}
&.right {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) @mask-width);
}
&.center {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) @mask-width, rgba(0, 0, 0, 1) calc(100% - @mask-width), rgba(0, 0, 0, 0) 100%);
}
}

View file

@ -0,0 +1,40 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useRef, useEffect, useState } from 'react';
import classNames from 'classnames';
import styles from './HorizontalScroll.less';
const SCROLL_THRESHOLD = 1;
type Props = {
className: string,
children: React.ReactNode,
};
const HorizontalScroll = ({ className, children }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [scrollPosition, setScrollPosition] = useState('left');
useEffect(() => {
const onScroll = ({ target }: Event) => {
const { scrollLeft, scrollWidth, offsetWidth } = target as HTMLDivElement;
setScrollPosition(() => (
(scrollLeft - SCROLL_THRESHOLD) <= 0 ? 'left' :
(scrollLeft + offsetWidth + SCROLL_THRESHOLD) >= scrollWidth ? 'right' :
'center'
));
};
ref.current?.addEventListener('scroll', onScroll);
return () => ref.current?.removeEventListener('scroll', onScroll);
}, []);
return (
<div ref={ref} className={classNames(styles['horizontal-scroll'], className, [styles[scrollPosition]])}>
{children}
</div>
);
};
export default HorizontalScroll;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2024 Smart code 203358507
import HorizontalScroll from './HorizontalScroll';
export default HorizontalScroll;

View file

@ -10,6 +10,7 @@ const TABS = [
{ id: 'board', label: 'Board', icon: 'home', href: '#/' },
{ id: 'discover', label: 'Discover', icon: 'discover', href: '#/discover' },
{ id: 'library', label: 'Library', icon: 'library', href: '#/library' },
{ id: 'calendar', label: 'Calendar', icon: 'calendar', href: '#/calendar' },
{ id: 'addons', label: 'ADDONS', icon: 'addons', href: '#/addons' },
{ id: 'settings', label: 'SETTINGS', icon: 'settings', href: '#/settings' },
];

View file

@ -9,6 +9,7 @@
align-items: center;
gap: 1rem;
width: var(--vertical-nav-bar-size);
padding: 1rem 0;
background-color: transparent;
overflow-y: auto;
scrollbar-width: none;
@ -18,16 +19,8 @@
}
.nav-tab-button {
width: calc(var(--vertical-nav-bar-size) - 1.5rem);
height: calc(var(--vertical-nav-bar-size) - 1.5rem);
&:first-child {
margin-top: 1rem;
}
&:last-child {
margin-bottom: 1rem;
}
width: calc(var(--vertical-nav-bar-size) - 1.2rem);
height: calc(var(--vertical-nav-bar-size) - 1.2rem);
}
}
@ -45,12 +38,18 @@
.nav-tab-button {
flex: none;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
display: none;
}
}
}
}
@media only screen and (max-height: @minimum) {
.vertical-nav-bar-container {
.nav-tab-button {
&:last-child {
display: none;
}
}
}

View file

@ -1,44 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const Button = require('stremio/common/Button');
const styles = require('./styles');
const PaginationInput = ({ className, label, dataset, onSelect, ...props }) => {
const prevNextButtonOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
onSelect({
type: 'change-page',
value: event.currentTarget.dataset.value,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onSelect]);
return (
<div {...props} className={classnames(className, styles['pagination-input-container'])} >
<Button className={styles['prev-button-container']} title={'Previous page'} data-value={'prev'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} name={'chevron-back'} />
</Button>
<div className={styles['label-container']} title={label}>
<div className={styles['label']}>{label}</div>
</div>
<Button className={styles['next-button-container']} title={'Next page'} data-value={'next'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} name={'chevron-forward'} />
</Button>
</div>
);
};
PaginationInput.propTypes = {
className: PropTypes.string,
label: PropTypes.string,
dataset: PropTypes.object,
onSelect: PropTypes.func
};
module.exports = PaginationInput;

View file

@ -1,5 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const PaginationInput = require('./PaginationInput');
module.exports = PaginationInput;

View file

@ -1,42 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.pagination-input-container {
display: flex;
flex-direction: row;
border-radius: var(--border-radius);
.prev-button-container, .next-button-container {
flex: none;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--overlay-color);
.icon {
display: block;
color: var(--primary-foreground-color);
}
}
.label-container {
flex: 1;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--overlay-color);
.label {
flex: none;
min-width: 1.2rem;
max-width: 3rem;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
font-weight: 500;
color: var(--primary-foreground-color);
}
}
}

View file

@ -19,4 +19,23 @@
opacity: 1;
transform: translateY(0);
}
}
:global(.animation-slide-up) {
:local {
animation-name: slide-up;
}
animation-timing-function: ease-out;
animation-duration: 0.1s;
}
@keyframes slide-up {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(0%);
}
}

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const AddonDetailsModal = require('./AddonDetailsModal');
const { default: BottomSheet } = require('./BottomSheet');
const Button = require('./Button');
const Toggle = require('./Toggle');
const { default: Chips } = require('./Chips');
@ -17,7 +18,7 @@ const ModalDialog = require('./ModalDialog');
const Multiselect = require('./Multiselect');
const { default: MultiselectMenu } = require('./MultiselectMenu');
const { HorizontalNavBar, VerticalNavBar } = require('./NavBar');
const PaginationInput = require('./PaginationInput');
const { default: HorizontalScroll } = require('./HorizontalScroll');
const { PlatformProvider, usePlatform } = require('./Platform');
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
const Popup = require('./Popup');
@ -51,6 +52,7 @@ const { default: Checkbox } = require('./Checkbox');
module.exports = {
AddonDetailsModal,
BottomSheet,
Button,
Toggle,
Chips,
@ -67,8 +69,8 @@ module.exports = {
Multiselect,
MultiselectMenu,
HorizontalNavBar,
HorizontalScroll,
VerticalNavBar,
PaginationInput,
PlatformProvider,
usePlatform,
PlayIconCircleCentered,

View file

@ -17,6 +17,10 @@ const routesRegexp = {
regexp: /^\/library(?:\/([^/]*))?$/,
urlParamsNames: ['type']
},
calendar: {
regexp: /^\/calendar(?:\/([^/]*)\/([^/]*))?$/,
urlParamsNames: ['year', 'month']
},
continuewatching: {
regexp: /^\/continuewatching(?:\/([^/]*))?$/,
urlParamsNames: ['type']

1
src/modules.d.ts vendored
View file

@ -3,4 +3,5 @@ declare module '*.less' {
export = resource;
}
declare module 'stremio/common';
declare module 'stremio/common/Button';

View file

@ -0,0 +1,43 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.calendar {
width: 100%;
height: 100%;
background-color: transparent;
.content {
position: relative;
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0 0 2rem 2rem;
.main {
flex: auto;
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
}
}
}
@media only screen and (max-width: @minimum) {
.calendar {
.content {
padding: 0;
}
}
}
@media only screen and (max-width: @small) and (orientation: landscape) {
.calendar {
.content {
padding: 0 0 0 1rem;
}
}
}

View file

@ -0,0 +1,75 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo, useState } from 'react';
import { MainNavBars, BottomSheet, useProfile, withCoreSuspender } from 'stremio/common';
import Selector from './Selector';
import Table from './Table';
import List from './List';
import Details from './Details';
import Placeholder from './Placeholder';
import useCalendar from './useCalendar';
import useCalendarDate from './useCalendarDate';
import styles from './Calendar.less';
type Props = {
urlParams: UrlParams,
};
const Calendar = ({ urlParams }: Props) => {
const calendar = useCalendar(urlParams);
const profile = useProfile();
const { toDayMonth } = useCalendarDate(profile);
const [selected, setSelected] = useState<CalendarDate | null>(null);
const detailsTitle = useMemo(() => toDayMonth(selected), [selected, toDayMonth]);
const onDetailsClose = () => {
setSelected(null);
};
return (
<MainNavBars className={styles['calendar']} route={'calendar'}>
{
profile.auth !== null ?
<div className={styles['content']}>
<div className={styles['main']}>
<Selector
selected={calendar.selected}
selectable={calendar.selectable}
profile={profile}
/>
<Table
items={calendar.items}
selected={selected}
monthInfo={calendar.monthInfo}
onChange={setSelected}
/>
</div>
<List
items={calendar.items}
selected={selected}
monthInfo={calendar.monthInfo}
profile={profile}
onChange={setSelected}
/>
<BottomSheet title={detailsTitle} show={selected} onClose={onDetailsClose}>
<Details
selected={selected}
items={calendar.items}
/>
</BottomSheet>
</div>
:
<Placeholder />
}
</MainNavBars>
);
};
const CalendarFallback = () => (
<MainNavBars className={styles['calendar']} />
);
export default withCoreSuspender(Calendar, CalendarFallback);

View file

@ -0,0 +1,60 @@
// Copyright (C) 2017-2024 Smart code 203358507
.details {
position: relative;
.video {
flex: none;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 1.5rem;
height: 4rem;
font-size: 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
-webkit-tap-highlight-color: transparent;
.name {
flex: auto;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.info {
flex: none;
display: block;
}
.icon {
flex: none;
width: 2rem;
height: 2rem;
padding: 0.5rem;
border-radius: 50%;
color: var(--primary-foreground-color);
}
&:hover, &:active {
background-color: var(--overlay-color);
.icon {
display: block;
background-color: var(--secondary-accent-color);
}
}
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 10rem;
font-size: 1rem;
color: var(--primary-foreground-color);
}
}

View file

@ -0,0 +1,45 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo } from 'react';
import Icon from '@stremio/stremio-icons/react';
import Button from 'stremio/common/Button';
import styles from './Details.less';
type Props = {
selected: CalendarDate | null,
items: CalendarItem[],
};
const Details = ({ selected, items }: Props) => {
const videos = useMemo(() => {
return items.find(({ date }) => date.day === selected?.day)?.items ?? [];
}, [selected, items]);
return (
<div className={styles['details']}>
{
videos.map(({ id, name, season, episode, deepLinks }) => (
<Button className={styles['video']} key={id} href={deepLinks.metaDetailsStreams}>
<div className={styles['name']}>
{name}
</div>
<div className={styles['info']}>
S{season}E{episode}
</div>
<Icon className={styles['icon']} name={'play'} />
</Button>
))
}
{
!videos.length ?
<div className={styles['placeholder']}>
No new episodes for this day
</div>
:
null
}
</div>
);
};
export default Details;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Details from './Details';
export default Details;

View file

@ -0,0 +1,98 @@
// Copyright (C) 2017-2024 Smart code 203358507
.item {
flex: none;
position: relative;
display: flex;
flex-direction: column;
background-color: var(--overlay-color);
border-radius: var(--border-radius);
border: 0.15rem solid transparent;
transition: border-color 0.1s ease-out;
.heading {
flex: none;
position: relative;
display: flex;
align-items: center;
height: 3.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
padding: 0 1rem;
}
.body {
flex: auto;
display: flex;
flex-direction: column;
.video {
flex: none;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 3rem;
padding: 0 1rem;
font-size: 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.name {
flex: auto;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.info {
flex: none;
display: block;
}
.icon {
flex: none;
display: none;
width: 2rem;
height: 2rem;
padding: 0.5rem;
border-radius: 50%;
color: var(--primary-foreground-color);
background-color: var(--secondary-accent-color);
}
&:hover {
background-color: var(--overlay-color);
.info {
display: none;
}
.icon {
display: block;
}
}
}
}
&.today {
.heading {
background-color: var(--primary-accent-color);
}
}
&.active {
border-color: var(--primary-foreground-color);
}
&:not(.active):hover {
border-color: var(--overlay-color);
}
}

View file

@ -0,0 +1,68 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useEffect, useMemo, useRef } from 'react';
import Icon from '@stremio/stremio-icons/react';
import classNames from 'classnames';
import { Button } from 'stremio/common';
import useCalendarDate from '../../useCalendarDate';
import styles from './Item.less';
type Props = {
selected: CalendarDate | null,
monthInfo: CalendarMonthInfo,
date: CalendarDate,
items: CalendarContentItem[],
profile: Profile,
onClick: (date: CalendarDate) => void,
};
const Item = ({ selected, monthInfo, date, items, profile, onClick }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const { toDayMonth } = useCalendarDate(profile);
const [active, today] = useMemo(() => [
date.day === selected?.day,
date.day === monthInfo.today,
], [selected, monthInfo, date]);
const onItemClick = () => {
onClick && onClick(date);
};
useEffect(() => {
active && ref.current?.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
}, [active]);
return (
<div
ref={ref}
className={classNames(styles['item'], { [styles['active']]: active, [styles['today']]: today })}
key={date.day}
onClick={onItemClick}
>
<div className={styles['heading']}>
{toDayMonth(date)}
</div>
<div className={styles['body']}>
{
items.map(({ id, name, season, episode, deepLinks }) => (
<Button className={styles['video']} key={id} href={deepLinks.metaDetailsStreams}>
<div className={styles['name']}>
{name}
</div>
<div className={styles['info']}>
S{season}E{episode}
</div>
<Icon className={styles['icon']} name={'play'} />
</Button>
))
}
</div>
</div>
);
};
export default Item;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Item from './Item';
export default Item;

View file

@ -0,0 +1,37 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.list {
flex: none;
display: flex;
flex-direction: column;
gap: 1rem;
width: 20rem;
padding: 0 1rem;
overflow-y: auto;
}
@media only screen and (max-width: @small) and (orientation: portrait) {
.list {
display: none;
}
}
@media only screen and (max-width: @medium) and (orientation: landscape) {
.list {
width: 20rem;
}
}
@media only screen and (max-width: @small) and (orientation: landscape) {
.list {
width: 17rem;
}
}
@media only screen and (max-width: @xsmall) and (orientation: landscape) {
.list {
display: none;
}
}

View file

@ -0,0 +1,38 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo } from 'react';
import Item from './Item';
import styles from './List.less';
type Props = {
items: CalendarItem[],
selected: CalendarDate | null,
monthInfo: CalendarMonthInfo,
profile: Profile,
onChange: (date: CalendarDate) => void,
};
const List = ({ items, selected, monthInfo, profile, onChange }: Props) => {
const filteredItems = useMemo(() => {
return items.filter(({ items }) => items.length);
}, [items]);
return (
<div className={styles['list']}>
{
filteredItems.map((item) => (
<Item
key={item.date.day}
{...item}
selected={selected}
monthInfo={monthInfo}
profile={profile}
onClick={onChange}
/>
))
}
</div>
);
};
export default List;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import List from './List';
export default List;

View file

@ -0,0 +1,99 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.placeholder {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
overflow-y: auto;
.title {
flex: none;
font-size: 1.75rem;
font-weight: 400;
text-align: center;
color: var(--primary-foreground-color);
margin-bottom: 1rem;
opacity: 0.5;
}
.image {
flex: none;
height: 14rem;
margin: 1.5rem 0;
}
.overview {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
gap: 4rem;
margin-bottom: 3rem;
.point {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
width: 18rem;
.icon {
flex: none;
height: 3.25rem;
width: 3.25rem;
color: var(--primary-foreground-color);
opacity: 0.3;
}
.text {
flex: auto;
font-size: 1.1rem;
font-size: 500;
color: var(--primary-foreground-color);
opacity: 0.9;
}
}
}
.button {
flex: none;
justify-content: center;
height: 4rem;
line-height: 4rem;
padding: 0 5rem;
font-size: 1.1rem;
color: var(--primary-foreground-color);
text-align: center;
border-radius: 3.5rem;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
}
}
@media only screen and (max-width: @minimum) {
.placeholder {
padding: 1rem 2rem;
.image {
height: 10rem;
}
.overview {
flex-direction: column;
}
.button {
width: 100%;
}
}
}

View file

@ -0,0 +1,43 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import { useTranslation } from 'react-i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button, Image } from 'stremio/common';
import styles from './Placeholder.less';
const Placeholder = () => {
const { t } = useTranslation();
return (
<div className={styles['placeholder']}>
<div className={styles['title']}>
{t('CALENDAR_NOT_LOGGED_IN')}
</div>
<Image
className={styles['image']}
src={require('/images/calendar_placeholder.png')}
alt={' '}
/>
<div className={styles['overview']}>
<div className={styles['point']}>
<Icon className={styles['icon']} name={'megaphone'} />
<div className={styles['text']}>
{t('NOT_LOGGED_IN_NOTIFICATIONS')}
</div>
</div>
<div className={styles['point']}>
<Icon className={styles['icon']} name={'calendar-thin'} />
<div className={styles['text']}>
{t('NOT_LOGGED_IN_CALENDAR')}
</div>
</div>
</div>
<Button className={styles['button']} href={'#/intro?form=login'}>
{t('LOG_IN')}
</Button>
</div>
);
};
export default Placeholder;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Placeholder from './Placeholder';
export default Placeholder;

View file

@ -0,0 +1,92 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.selector {
flex: none;
position: relative;
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
padding: 0 1rem;
.prev, .next {
position: relative;
height: 3rem;
width: 6rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-radius: 0.5rem;
transition: background-color 0.1s ease-out;
-webkit-tap-highlight-color: transparent;
.label, .icon {
color: var(--primary-foreground-color);
opacity: 0.5;
transition: opacity 0.1s ease-out;
}
.label {
font-size: 1rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.icon {
height: 1.5rem;
}
&:hover {
.label, .icon {
opacity: 1;
}
background-color: var(--overlay-color);
}
}
.prev {
padding-left: 0.5rem;
padding-right: 1.25rem;
}
.next {
padding-left: 1.25rem;
padding-right: 0.5rem;
}
.selected {
position: relative;
width: 8.5rem;
text-align: center;
.year {
font-size: 1rem;
font-weight: 500;
line-height: 100%;
color: var(--primary-foreground-color);
opacity: 0.5;
}
.month {
font-size: 1.5rem;
font-weight: 500;
color: var(--primary-foreground-color);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
@media only screen and (max-width: @small) {
.selector {
justify-content: space-between;
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useCallback, useMemo } from 'react';
import Icon from '@stremio/stremio-icons/react';
import { Button } from 'stremio/common';
import useCalendarDate from '../useCalendarDate';
import styles from './Selector.less';
type Props = {
selected: CalendarSelected,
selectable: CalendarSelectable,
profile: Profile,
};
const Selector = ({ selected, selectable, profile }: Props) => {
const { toMonth } = useCalendarDate(profile);
const [prev, next] = useMemo(() => (
[selectable.prev, selectable.next]
), [selectable]);
const onPrev = useCallback(() => {
window.location.href = prev.deepLinks.calendar;
}, [prev]);
const onNext = useCallback(() => {
window.location.href = next.deepLinks.calendar;
}, [next]);
return (
<div className={styles['selector']}>
<Button className={styles['prev']} onClick={onPrev}>
<Icon
className={styles['icon']}
name={'chevron-back'}
/>
<div className={styles['label']}>
{toMonth(prev, 'short')}
</div>
</Button>
<div className={styles['selected']}>
<div className={styles['year']}>
{selected?.year}
</div>
<div className={styles['month']}>
{toMonth(selected, 'long')}
</div>
</div>
<Button className={styles['next']} onClick={onNext}>
<div className={styles['label']}>
{toMonth(next, 'short')}
</div>
<Icon
className={styles['icon']}
name={'chevron-forward'}
/>
</Button>
</div>
);
};
export default Selector;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Selector from './Selector';
export default Selector;

View file

@ -0,0 +1,177 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.cell {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.5rem;
background-color: var(--overlay-color);
border: 0.15rem solid transparent;
overflow: hidden;
cursor: pointer;
transition: border-color 0.1s ease-out;
-webkit-tap-highlight-color: transparent;
&:first-child {
border-radius: var(--border-radius) 0 0 0;
}
&:nth-child(7) {
border-radius: 0 var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) 0;
}
.heading {
flex: none;
position: relative;
height: 3rem;
display: flex;
align-items: center;
padding: 0 1rem;
.day {
flex: none;
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 2rem;
width: 2rem;
border-radius: 100%;
font-size: 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
}
}
.items {
flex: 0 1 10rem;
position: relative;
display: flex;
flex-direction: row;
gap: 1rem;
padding: 0 1rem 1rem 1rem;
.item {
flex: none;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
aspect-ratio: 2 / 3;
border-radius: var(--border-radius);
.icon {
flex: none;
z-index: 1;
position: absolute;
width: 2rem;
height: 2rem;
padding: 0.5rem;
border-radius: 50%;
color: var(--primary-foreground-color);
background-color: var(--secondary-accent-color);
opacity: 0;
}
.poster {
flex: auto;
z-index: 0;
position: relative;
height: 100%;
width: 100%;
object-fit: cover;
opacity: 1;
}
.icon, .poster {
transition: opacity 0.1s ease-out;
}
&:hover {
.icon {
opacity: 1;
}
.poster {
opacity: 0.5;
}
}
}
}
.more {
display: none;
flex: none;
width: 2rem;
height: 2rem;
padding: 0.5rem;
align-self: center;
color: var(--primary-foreground-color);
}
&.today {
.heading {
.day {
background-color: var(--primary-accent-color);
}
}
}
&.active {
border-color: var(--primary-foreground-color);
}
&:not(.active):hover {
border-color: var(--overlay-color);
}
}
@media only screen and (orientation: portrait) {
.cell {
.heading {
justify-content: center;
}
.items {
display: none;
}
.more {
display: flex;
}
}
}
@media only screen and (max-width: @small) and (orientation: landscape) {
.cell {
flex-direction: row;
align-items: center;
.items {
display: none;
}
.more {
display: flex;
}
}
}
@media only screen and (max-height: @xxsmall) and (orientation: landscape) {
.cell {
.items {
display: none;
}
.more {
display: flex;
}
}
}

View file

@ -0,0 +1,61 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo } from 'react';
import Icon from '@stremio/stremio-icons/react';
import classNames from 'classnames';
import { Button, Image, HorizontalScroll } from 'stremio/common';
import styles from './Cell.less';
type Props = {
selected: CalendarDate | null,
monthInfo: CalendarMonthInfo,
date: CalendarDate,
items: CalendarContentItem[],
onClick: (date: CalendarDate) => void,
};
const Cell = ({ selected, monthInfo, date, items, onClick }: Props) => {
const [active, today] = useMemo(() => [
date.day === selected?.day,
date.day === monthInfo.today,
], [selected, monthInfo, date]);
const onCellClick = () => {
onClick && onClick(date);
};
return (
<Button
className={classNames(styles['cell'], { [styles['active']]: active, [styles['today']]: today })}
onClick={onCellClick}
>
<div className={styles['heading']}>
<div className={styles['day']}>
{date.day}
</div>
</div>
<HorizontalScroll className={styles['items']}>
{
items.map(({ id, name, poster, deepLinks }) => (
<Button key={id} className={styles['item']} href={deepLinks.metaDetailsStreams}>
<Icon className={styles['icon']} name={'play'} />
<Image
className={styles['poster']}
src={poster}
alt={name}
/>
</Button>
))
}
</HorizontalScroll>
{
items.length > 0 ?
<Icon className={styles['more']} name={'more-horizontal'} />
:
null
}
</Button>
);
};
export default Cell;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Cell from './Cell';
export default Cell;

View file

@ -0,0 +1,65 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.table {
flex: auto;
position: relative;
display: flex;
flex-direction: column;
.week {
flex: none;
position: relative;
height: 3rem;
width: 100%;
display: grid;
grid-template-columns: repeat(7, 1fr);
align-items: center;
.day {
position: relative;
padding: 0.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.long {
display: block;
}
.short {
display: none;
}
}
}
.grid {
flex: auto;
position: relative;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
}
}
@media only screen and (max-width: @xsmall) {
.table {
.week {
.day {
.long {
display: none;
}
.short {
display: block;
}
}
}
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './Table.less';
import Cell from './Cell/Cell';
const WEEK_DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
type Props = {
items: CalendarItem[],
selected: CalendarDate | null,
monthInfo: CalendarMonthInfo,
onChange: (date: CalendarDate) => void,
};
const Table = ({ items, selected, monthInfo, onChange }: Props) => {
const { t } = useTranslation();
const cellsOffset = useMemo(() => {
return Array.from(Array(monthInfo.firstWeekday).keys());
}, [monthInfo]);
return (
<div className={styles['table']}>
<div className={styles['week']}>
{
WEEK_DAYS.map((day) => (
<div className={styles['day']} key={day}>
<span className={styles['long']}>
{t(day)}
</span>
<span className={styles['short']}>
{t(day).slice(0, 3)}
</span>
</div>
))
}
</div>
<div className={styles['grid']}>
{
cellsOffset.map((day) => (
<span key={day} />
))
}
{
items.map((item) => (
<Cell
key={item.date.day}
{...item}
selected={selected}
monthInfo={monthInfo}
onClick={onChange}
/>
))
}
</div>
</div>
);
};
export default Table;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Table from './Table';
export default Table;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2024 Smart code 203358507
import Calendar from './Calendar';
export default Calendar;

View file

@ -0,0 +1,27 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import { useModelState } from 'stremio/common';
const useCalendar = (urlParams: UrlParams) => {
const action = React.useMemo(() => {
const args = urlParams.year && urlParams.month ? {
year: parseInt(urlParams.year),
month: parseInt(urlParams.month),
day: urlParams.day ? parseInt(urlParams.day) : null,
} : null;
return {
action: 'Load',
args: {
model: 'Calendar',
args,
},
};
}, [urlParams]);
const calendar = useModelState({ model: 'calendar', action }) as Calendar;
return calendar;
};
export default useCalendar;

View file

@ -0,0 +1,50 @@
import { useCallback } from 'react';
const useCalendarDate = (profile: Profile) => {
const toMonth = useCallback((calendarDate: CalendarDate | CalendarSelectableDate | null, format: 'short' | 'long'): string => {
if (!calendarDate) return '';
const date = new Date();
date.setDate(1);
date.setMonth(calendarDate.month - 1);
return date.toLocaleString(profile.settings.interfaceLanguage, {
month: format,
});
}, [profile.settings]);
const toMonthYear = useCallback((calendarDate: CalendarDate | null): string => {
if (!calendarDate) return '';
const date = new Date();
date.setDate(1);
date.setMonth(calendarDate.month - 1);
date.setFullYear(calendarDate.year);
return date.toLocaleString(profile.settings.interfaceLanguage, {
month: 'long',
year: 'numeric',
});
}, [profile.settings]);
const toDayMonth = useCallback((calendarDate: CalendarDate | null): string => {
if (!calendarDate) return '';
const date = new Date();
date.setDate(calendarDate.day);
date.setMonth(calendarDate.month - 1);
return date.toLocaleString(profile.settings.interfaceLanguage, {
day: 'numeric',
month: 'short',
});
}, [profile.settings]);
return {
toMonth,
toMonthYear,
toDayMonth,
};
};
export default useCalendarDate;

View file

@ -11,13 +11,6 @@
multiselect-label: label;
}
:import('~stremio/common/PaginationInput/styles.less') {
pagination-prev-button-container: prev-button-container;
pagination-next-button-container: next-button-container;
pagination-button-icon: icon;
pagination-label: label;
}
:import('~stremio/common/ModalDialog/styles.less') {
selectable-inputs-modal-container: modal-dialog-container;
selectable-inputs-modal-content: modal-dialog-content;

View file

@ -4,6 +4,7 @@ const Addons = require('./Addons');
const Board = require('./Board');
const Discover = require('./Discover');
const Library = require('./Library');
const Calendar = require('./Calendar').default;
const MetaDetails = require('./MetaDetails');
const NotFound = require('./NotFound');
const Search = require('./Search');
@ -16,6 +17,7 @@ module.exports = {
Board,
Discover,
Library,
Calendar,
MetaDetails,
NotFound,
Search,

View file

@ -35,10 +35,15 @@ function KeyboardShortcuts() {
}
case 'Digit4': {
event.preventDefault();
window.location = '#/addons';
window.location = '#/calendar';
break;
}
case 'Digit5': {
event.preventDefault();
window.location = '#/addons';
break;
}
case 'Digit6': {
event.preventDefault();
window.location = '#/settings';
break;

55
src/types/models/Calendar.d.ts vendored Normal file
View file

@ -0,0 +1,55 @@
type CalendarDeepLinks = {
calendar: string,
};
type CalendarItemDeepLinks = {
metaDetailsStreams: string,
};
type CalendarSelectableDate = {
month: number,
year: number,
selected: boolean,
deepLinks: CalendarDeepLinks,
};
type CalendarSelectable = {
prev: CalendarSelectableDate,
next: CalendarSelectableDate,
};
type CalendarDate = {
day: number,
month: number,
year: number,
};
type CalendarSelected = CalendarDate | null;
type CalendarMonthInfo = {
today: number | null,
days: number,
firstWeekday: number,
};
type CalendarContentItem = {
id: string,
name: string,
poster?: string,
title: string,
season?: number,
episode?: number,
deepLinks: CalendarItemDeepLinks,
};
type CalendarItem = {
date: CalendarDate,
items: CalendarContentItem[],
};
type Calendar = {
selectable: CalendarSelectable,
selected: CalendarSelected,
monthInfo: CalendarMonthInfo,
items: CalendarItem[],
};