Skip to content

Commit 77b5755

Browse files
quality of life, remote control options and more responsiveness (#14)
* Update dependencies and refactor component imports; rename files to .jsx * implement search, refactor code, add popular and settings * save position when going back * keep dom grid * add about page, remove switzerland * fix scroll position after list/video * fix scroll position after list/video * update screenshots * keycode 460 for subtitles * pause playback when selecting subtitles using the rc button * add cond virtualization, remove spacer loader * refactor css a bit * refactor css * adjust subtitles matrix color * fix search css, adjust settings border radius and carousel active item * improve startup performance and navigation responsiveness * improve startup performance and navigation responsiveness * improve startup performance and navigation responsiveness * Update app version in appinfo.json --------- Co-authored-by: Lotovic <volkovmqx@gmail.com>
1 parent 78a2a1c commit 77b5755

27 files changed

Lines changed: 1104 additions & 905 deletions
-458 KB
Loading
-137 KB
Loading

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
node_modules/
22
dist/
3-
org.webowsbrew.cccbib*
3+
org.webosbrew.cccbib_*.ipk

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ This app uses the official media.ccc.de/graphql endpoint. It is built using Reac
1919
- Smart TV Navigation with React! (Thanks to [this](https://noriginmedia.com/2019/06/24/smart-tv-navigation-with-react/) and [this](https://medium.com/@utkarsh.gupta.316/spatial-navigation-in-react-js-for-smart-tv-3aa6977a94b1).)
2020
- Search functionnality.
2121
- Watchlist.
22-
- Quality of Life remote button usage.
22+
- Quality of Life Remote Controller Button Mapping (Thanks to [Simon34545](https://github.com/Simon34545) LG Input Keymapping overview!) inspired by Youtube/Jellyfin.
2323
- Configuration page for preferred languages and subtitles.
2424
- Substitles functionnality.
25+
- About Info page (in Settings on the bottom).
2526

2627
## Roadmap
2728

2829
- &#9744; Add Speaker page.
29-
- &#9744; Add Info page.
30+
- &#9744; Trigger for remote control/Home Assistant integration (Watching now/Edit Watchlist).
31+
- ~~&#9744; Jeopardy Theme song when waiting for too long.~~
3032

3133
## Contributions
3234

assets/appinfo.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
2-
"id": "org.webosbrew.cccbib",
3-
"version": "1.0.1",
4-
"vendor": "volkovmqx",
5-
"type": "web",
6-
"main": "index.html",
7-
"title": "CCCBib",
8-
"iconColor": "#000000",
9-
"appDescription": "media.ccc.de library application (EXPERIMENTAL)",
10-
"icon": "icon80.png",
11-
"splashBackground": "splash.png",
12-
"largeIcon": "icon130.png",
13-
"disableBackHistoryAPI": true
2+
"id": "org.webosbrew.cccbib",
3+
"version": "1.0.2",
4+
"vendor": "volkovmqx",
5+
"type": "web",
6+
"main": "index.html",
7+
"title": "CCCBib",
8+
"iconColor": "#000000",
9+
"appDescription": "media.ccc.de library application (EXPERIMENTAL)",
10+
"icon": "icon80.png",
11+
"splashBackground": "splash.png",
12+
"largeIcon": "icon130.png",
13+
"disableBackHistoryAPI": true
1414
}

babel.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
module.exports = {
2+
compact: false,
23
presets: [
34
[
45
'@babel/preset-env',
56
{
67
targets: {
8+
// webOS 2.0+ uses Chrome 38
79
chrome: '38',
810
},
911
useBuiltIns: 'entry',

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "org.webosbrew.cccbib",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"description": "media.ccc.de library application (EXPERIMENTAL)",
55
"main": "index.js",
66
"scripts": {
@@ -29,6 +29,7 @@
2929
"style-loader": "^3.3.1",
3030
"vite": "^7.3.0",
3131
"webpack": "^5.104.1",
32+
"webpack-bundle-analyzer": "^5.1.1",
3233
"webpack-cli": "^5.1.4",
3334
"webpack-dev-server": "^4.15.1"
3435
},

src/components/ConferenceView.jsx

Lines changed: 147 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useRef, useEffect } from 'react';
1+
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
22
import { Center, Loader, Container, Title } from '@mantine/core';
33
import { useWindowEvent } from '@mantine/hooks';
44
import { useQuery } from '@apollo/client';
@@ -8,7 +8,7 @@ import { Player } from './Player';
88

99
import '../styles.css';
1010

11-
export const ConferenceView = React.memo(function ConferenceView({ conferenceId, onFocusSidebar, sidebarFocused, setIsPlayerFullscreen }) {
11+
export const ConferenceView = React.memo(function ConferenceView({ conferenceId, onFocusSidebar, sidebarFocused, setIsPlayerFullscreen, onClose }) {
1212
const [playerIsOpen, setPlayerIsOpen] = useState(false);
1313
const [activeEvent, setActiveEvent] = useState(0);
1414
const selectedItemRef = useRef(null);
@@ -20,28 +20,96 @@ export const ConferenceView = React.memo(function ConferenceView({ conferenceId,
2020
});
2121

2222
const eventsPerRow = 4;
23+
const visibleRowBuffer = 3; // Render ±3 rows around active row
24+
const itemHeight = 180; // Approximate height of each grid item in px
25+
26+
// Calculate visible window of events for virtual scrolling
27+
const { visibleEvents, topSpacerHeight, bottomSpacerHeight } = useMemo(() => {
28+
if (!data?.conference?.lectures?.nodes) {
29+
return { visibleEvents: [], topSpacerHeight: 0, bottomSpacerHeight: 0 };
30+
}
31+
32+
const events = data.conference.lectures.nodes;
33+
const totalEvents = events.length;
34+
const totalRows = Math.ceil(totalEvents / eventsPerRow);
35+
const activeRow = Math.floor(activeEvent / eventsPerRow);
36+
37+
const startRow = Math.max(0, activeRow - visibleRowBuffer);
38+
const endRow = Math.min(totalRows - 1, activeRow + visibleRowBuffer);
39+
40+
const startIndex = startRow * eventsPerRow;
41+
const endIndex = Math.min(totalEvents, (endRow + 1) * eventsPerRow);
42+
43+
return {
44+
visibleEvents: events.slice(startIndex, endIndex).map((event, i) => ({
45+
...event,
46+
originalIndex: startIndex + i
47+
})),
48+
topSpacerHeight: startRow * itemHeight,
49+
bottomSpacerHeight: Math.max(0, (totalRows - endRow - 1) * itemHeight)
50+
};
51+
}, [data, activeEvent, eventsPerRow, visibleRowBuffer, itemHeight]);
52+
53+
// Keep scroll position
54+
const savedScrollPositionRef = useRef(0);
55+
const playerJustClosedRef = useRef(false);
56+
57+
const handleScroll = useCallback(() => {
58+
if (containerRef.current) {
59+
savedScrollPositionRef.current = containerRef.current.scrollTop;
60+
}
61+
}, []);
2362

24-
// Scroll selected item into view with header offset
2563
useEffect(() => {
26-
if (selectedItemRef.current && containerRef.current) {
27-
const container = containerRef.current;
28-
const item = selectedItemRef.current;
29-
const headerOffset = 70; // Account for sticky header
30-
31-
const itemTop = item.offsetTop - headerOffset;
32-
const itemBottom = item.offsetTop + item.offsetHeight;
33-
const containerScrollTop = container.scrollTop;
34-
const containerHeight = container.clientHeight;
35-
36-
// Check if item is above visible area
37-
if (itemTop < containerScrollTop) {
38-
container.scrollTop = itemTop;
39-
}
40-
// Check if item is below visible area
41-
else if (itemBottom > containerScrollTop + containerHeight) {
42-
container.scrollTop = itemBottom - containerHeight + 20;
43-
}
64+
if (playerIsOpen) {
65+
playerJustClosedRef.current = false;
66+
return;
4467
}
68+
69+
playerJustClosedRef.current = true;
70+
const resetTimeout = setTimeout(() => {
71+
playerJustClosedRef.current = false;
72+
}, 300);
73+
74+
if (savedScrollPositionRef.current > 0) {
75+
const scrollTimeout = setTimeout(() => {
76+
if (containerRef.current) {
77+
containerRef.current.scrollTop = savedScrollPositionRef.current;
78+
}
79+
}, 100);
80+
return () => {
81+
clearTimeout(resetTimeout);
82+
clearTimeout(scrollTimeout);
83+
};
84+
}
85+
86+
return () => clearTimeout(resetTimeout);
87+
}, [playerIsOpen]);
88+
89+
useEffect(() => {
90+
if (playerIsOpen) return;
91+
92+
const timeoutId = setTimeout(() => {
93+
if (selectedItemRef.current && containerRef.current) {
94+
const container = containerRef.current;
95+
const item = selectedItemRef.current;
96+
const headerOffset = 70;
97+
98+
const itemTop = item.offsetTop - headerOffset;
99+
const itemBottom = item.offsetTop + item.offsetHeight;
100+
const containerScrollTop = container.scrollTop;
101+
const containerHeight = container.clientHeight;
102+
103+
if (itemTop < containerScrollTop) {
104+
container.scrollTop = itemTop;
105+
} else if (itemBottom > containerScrollTop + containerHeight) {
106+
container.scrollTop = itemBottom - containerHeight + 20;
107+
}
108+
savedScrollPositionRef.current = container.scrollTop;
109+
}
110+
}, 50);
111+
112+
return () => clearTimeout(timeoutId);
45113
}, [activeEvent]);
46114

47115
// Keyboard navigation
@@ -55,7 +123,12 @@ export const ConferenceView = React.memo(function ConferenceView({ conferenceId,
55123
// Don't handle keyboard events if sidebar is focused
56124
if (sidebarFocused) return;
57125

58-
if (e.key === 'Enter') {
126+
if (e.key === 'Escape' || e.keyCode === 461) {
127+
e.preventDefault();
128+
if (!playerJustClosedRef.current) {
129+
onClose?.();
130+
}
131+
} else if (e.key === 'Enter') {
59132
setPlayerIsOpen(true);
60133
setIsPlayerFullscreen(true);
61134
} else if (e.key === 'ArrowRight') {
@@ -101,51 +174,63 @@ export const ConferenceView = React.memo(function ConferenceView({ conferenceId,
101174
const conference = data.conference;
102175
const events = conference.lectures.nodes;
103176

104-
if (playerIsOpen) {
105-
return (
106-
<div className="player-fullscreen-container">
107-
<Player
177+
return (
178+
<>
179+
{playerIsOpen && (
180+
<div className="player-fullscreen-container">
181+
<Player
182+
event={events[activeEvent]}
183+
conferenceTitle={conference.title}
184+
onClose={() => {
185+
setPlayerIsOpen(false);
186+
setIsPlayerFullscreen(false);
187+
}}
188+
/>
189+
</div>
190+
)}
191+
<Container fluid style={{ display: playerIsOpen ? 'none' : 'block' }}>
192+
<Preview
108193
event={events[activeEvent]}
109194
conferenceTitle={conference.title}
110-
onClose={() => {
111-
setPlayerIsOpen(false);
112-
setIsPlayerFullscreen(false);
113-
}}
195+
disableVideo={playerIsOpen}
114196
/>
115-
</div>
116-
);
117-
}
118-
119-
return (
120-
<Container fluid>
121-
<Preview
122-
event={events[activeEvent]}
123-
conferenceTitle={conference.title}
124-
/>
125-
<div className="conferenceView__container" ref={containerRef}>
126-
<div className="conferenceView__header">
127-
<h2 className="conferenceView__title">{conference.title}</h2>
128-
</div>
129-
<div className="conferenceView__grid">
130-
{events.map((event, index) => (
131-
<div
132-
key={event.guid}
133-
ref={index === activeEvent ? selectedItemRef : null}
134-
className={`conferenceView__gridItem ${index === activeEvent ? 'conferenceView__gridItem--active' : ''}`}
135-
onClick={() => setActiveEvent(index)}
136-
>
137-
<img
138-
src={event.images.thumbUrl}
139-
alt={event.title}
140-
className="conferenceView__gridItem__img"
141-
loading="lazy"
197+
<div className="conferenceView__container scrollable" ref={containerRef} onScroll={handleScroll}>
198+
<div className="conferenceView__header">
199+
<h2 className="conferenceView__title">{conference.title}</h2>
200+
</div>
201+
<div className="conferenceView__grid">
202+
{topSpacerHeight > 0 && (
203+
<div
204+
className="conferenceView__gridSpacer"
205+
style={{ height: topSpacerHeight, gridColumn: '1 / -1' }}
206+
/>
207+
)}
208+
{visibleEvents.map((event) => (
209+
<div
210+
key={event.guid}
211+
ref={event.originalIndex === activeEvent ? selectedItemRef : null}
212+
className={`conferenceView__gridItem ${event.originalIndex === activeEvent ? 'conferenceView__gridItem--active' : ''}`}
213+
onClick={() => setActiveEvent(event.originalIndex)}
214+
>
215+
<img
216+
src={event.images.thumbUrl}
217+
alt={event.title}
218+
className="conferenceView__gridItem__img"
219+
loading="lazy"
220+
/>
221+
<div className="conferenceView__gridItem__title">{event.title}</div>
222+
</div>
223+
))}
224+
{bottomSpacerHeight > 0 && (
225+
<div
226+
className="conferenceView__gridSpacer"
227+
style={{ height: bottomSpacerHeight, gridColumn: '1 / -1' }}
142228
/>
143-
<div className="conferenceView__gridItem__title">{event.title}</div>
144-
</div>
145-
))}
229+
)}
230+
</div>
146231
</div>
147-
</div>
148-
</Container>
232+
</Container>
233+
</>
149234
);
150235
});
151236

src/components/EventCarousel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { IconWorld, IconBadgeCc } from '@tabler/icons-react';
55
import { useImagePreload } from '../hooks/useImagePreload';
66

77
export const EventCarousel = React.memo(function EventCarousel({ eventApis, eventRefs, events, activeEvent, ci, conferenceTitle }) {
8-
const [emblaRef, emblaApi] = useEmblaCarousel({ align: 'start', startIndex: activeEvent || 0 });
8+
const [emblaRef, emblaApi] = useEmblaCarousel({ align: 'start', startIndex: activeEvent || 0, duration: 15 });
99

1010
useEffect(() => {
1111
if (emblaRef && emblaApi) {

src/components/EventsListView.jsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { ListView } from './ListView';
44

55
import '../styles.css';
66

7-
export const EventsListView = React.memo(function EventsListView({ onClose, onSelectEvent, onFocusSidebar, sidebarFocused }) {
7+
export const EventsListView = React.memo(function EventsListView({ onClose, onSelectEvent, onFocusSidebar, sidebarFocused, selectedIndex, setSelectedIndex }) {
88
const [conferences, setConferences] = useState([]);
99
const [loading, setLoading] = useState(true);
1010
const [error, setError] = useState(null);
11-
const [selectedIndex, setSelectedIndex] = useState(0);
1211

1312
useEffect(() => {
1413
const fetchConferences = async () => {

0 commit comments

Comments
 (0)