From b1a93161c05e475a65b86dcf5ee31ad9ce5aa19f Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Mon, 9 Mar 2026 13:29:23 -0400 Subject: [PATCH] feat(main): main --- .DS_Store | Bin 10244 -> 12292 bytes .gitignore | 4 + Dockerfile | 7 + api/api.py | 3 +- api/routers/channel.py | 12 +- api/routers/library.py | 14 +- api/routers/sources.py | 18 ++ assets/TestCard.svg | 1 + .../0004_channel_fallback_collection.py | 25 ++ core/models.py | 1 + core/services/scheduler.py | 42 ++- core/services/youtube.py | 16 ++ db.sqlite3 | Bin 565248 -> 569344 bytes docker-compose.yml | 16 ++ frontend/public/testcard.svg | 1 + frontend/src/api.js | 2 + frontend/src/components/ChannelTuner.jsx | 195 ++++++------- frontend/src/components/Guide.jsx | 267 +++++++++++++----- frontend/src/components/Settings.jsx | 138 ++++++++- frontend/src/index.css | 108 +++++++ package-lock.json | 22 ++ package.json | 19 ++ 22 files changed, 719 insertions(+), 192 deletions(-) create mode 100644 assets/TestCard.svg create mode 100644 core/migrations/0004_channel_fallback_collection.py create mode 100644 frontend/public/testcard.svg create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.DS_Store b/.DS_Store index ec6114f377bb0a5cb13146c5681f0b71586b66d1..e21c2660820ec7a5f8a57ca659cf8e07599d15b7 100644 GIT binary patch delta 1407 zcmb`HUu+ab9LMLocP+Er#?BpA4qCQ*+?{O+S9{k|S}3W7QfTFBQCet$Sns;q-rD=q z-kz<{luGy$jS$6E6P{2(ABnXZ{{^BxnP_7)7avR{Moq*QeIX{*P@TOkr1Ic{agy1Y z-*0}uncwdBvvYd?>Bs$q5bSzVjS^DJr<8YVWD+6H)r9z67yLD{!wTY^I|-jNLWW6_ zXenc|Baxb$xa=?^A?F$^!pVCHUya)l=Av(tIG_(_GY9nJn)#gf*vJs`OzPTH?|5F% zW{iVc&fsIPJ;D=t6R+OqQ$n?M4b3~+JG*-xDU>m{N0Q3d9-7n*J(1D|s`0$mlU1!KwYZ}!2Wlui zV);9A8h_euN!}dh3GqIISG#<4Br1x`=o`~B%c)Y+Rw^>JKNU}CDSJ0*dn|Vr!rdZPDYH< zAy!uOTPdBYp3mkAo^|Uh9uCOzptaRbRn=5V3l;0ud*lOhKV@IjA#-Pi*U^5e-E5QP zS+D(l?>@n$mPs{fA?>7(jF2ojNzRc=6TjdVZet0532vc62nqKK)k3`x5gLR>VUMs^*e?tU31L)7nk|(Z z%@b?RS~yzJ@kU*96F%pBjr5bFtd!d;8SL=}n6kI4`ynaz4-~8Z9YwRMJUJ^>Dg9h| z?WudYyTc<}Wl={9T#d?&Qcz(foDJKomTDDN%DJ%3%}SlZ1dgL^MwAALIXG-$F}F#n zlNhLNY?E3g=H%D~Y`aaWk{A(fW0$g9VHQM`2LGG7Uy*Og_gvjuWD%usfk8nn9zY9M za}T=EjTm~kq5~Mj5gdhvQ6%vM*Ef$zOyL-g;}p(tb)Uhrco8p!@iJb)CA`KpzJfQn z!f#;?Z{s6e!*zU&Pl{R>@Ev}@kLHpbbgj^5-tGIH&OP1eM9I9wysLaG))G_3cp{Ze za78~fn|u?SDyF9!nD@X^C*b0Z~iFsoI{WqBCo&=q+LPA lZx-bE&ODi4$8)kj4+jSm!~%`U2|6=3GpfonP5vnK1OU{T9=`wp diff --git a/.gitignore b/.gitignore index 505a3b1..3d5cd29 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ wheels/ # Virtual environments .venv + +node_modules/ +mock/ +cache/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 138e399..1c9d132 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM python:3.12-slim-bookworm # System dependencies for psycopg/postgres and general utilities +# System dependencies for psycopg/postgres, nodejs, and general utilities RUN apt-get update && apt-get install -y \ libpq-dev \ gcc \ curl \ + gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* # Install uv globally @@ -19,6 +23,9 @@ COPY . /app # --system ensures it installs into the global python environment rather than venv RUN uv pip install --system django psycopg django-environ gunicorn django-ninja django-cors-headers yt-dlp +# Install npm dependencies +RUN npm install + # Expose Django default port EXPOSE 8000 diff --git a/api/api.py b/api/api.py index cd6565e..1dbdb20 100644 --- a/api/api.py +++ b/api/api.py @@ -2,13 +2,14 @@ from ninja import NinjaAPI api = NinjaAPI(title="PYTV API") -from api.routers.library import router as library_router +from api.routers.library import router as library_router, collections_router from api.routers.channel import router as channel_router from api.routers.schedule import router as schedule_router from api.routers.user import router as user_router from api.routers.sources import router as sources_router api.add_router("/library/", library_router) +api.add_router("/collections/", collections_router) api.add_router("/channel/", channel_router) api.add_router("/schedule/", schedule_router) api.add_router("/user/", user_router) diff --git a/api/routers/channel.py b/api/routers/channel.py index bf69ba2..729df45 100644 --- a/api/routers/channel.py +++ b/api/routers/channel.py @@ -16,6 +16,7 @@ class ChannelSchema(Schema): scheduling_mode: str library_id: int owner_user_id: int + fallback_collection_id: Optional[int] = None class ChannelCreateSchema(Schema): name: str @@ -24,6 +25,7 @@ class ChannelCreateSchema(Schema): description: Optional[str] = None library_id: int owner_user_id: int # Mock Auth User + fallback_collection_id: Optional[int] = None class ChannelUpdateSchema(Schema): name: Optional[str] = None @@ -32,6 +34,7 @@ class ChannelUpdateSchema(Schema): scheduling_mode: Optional[str] = None visibility: Optional[str] = None is_active: Optional[bool] = None + fallback_collection_id: Optional[int] = None class ChannelSourceRuleSchema(Schema): id: int @@ -103,7 +106,11 @@ class AiringSchema(Schema): @router.get("/", response=List[ChannelSchema]) def list_channels(request): - return Channel.objects.all() + from django.db.models import F + return Channel.objects.order_by( + F('channel_number').asc(nulls_last=True) + ) + @router.get("/{channel_id}", response=ChannelSchema) def get_channel(request, channel_id: int): @@ -120,7 +127,8 @@ def create_channel(request, payload: ChannelCreateSchema): name=payload.name, slug=payload.slug, channel_number=payload.channel_number, - description=payload.description + description=payload.description, + fallback_collection_id=payload.fallback_collection_id ) return 201, channel diff --git a/api/routers/library.py b/api/routers/library.py index d29a85b..803c15a 100644 --- a/api/routers/library.py +++ b/api/routers/library.py @@ -1,9 +1,10 @@ from ninja import Router, Schema from typing import List, Optional -from core.models import Library, AppUser +from core.models import Library, AppUser, MediaCollection from django.shortcuts import get_object_or_404 router = Router(tags=["library"]) +collections_router = Router(tags=["collections"]) class LibrarySchema(Schema): id: int @@ -18,6 +19,12 @@ class LibraryCreateSchema(Schema): visibility: Optional[str] = 'private' owner_user_id: int # In a real app with auth, this would come from request.user +class MediaCollectionSchema(Schema): + id: int + name: str + library_id: int + collection_type: str + @router.get("/", response=List[LibrarySchema]) def list_libraries(request): return Library.objects.all() @@ -36,3 +43,8 @@ def create_library(request, payload: LibraryCreateSchema): visibility=payload.visibility ) return 201, library + +@collections_router.get("/", response=List[MediaCollectionSchema]) +def list_collections(request): + """List all MediaCollections across all libraries.""" + return MediaCollection.objects.select_related('library').all() diff --git a/api/routers/sources.py b/api/routers/sources.py index e29c4bb..7fcfccd 100644 --- a/api/routers/sources.py +++ b/api/routers/sources.py @@ -176,3 +176,21 @@ def download_item(request, item_id: int): except Exception as exc: from ninja.errors import HttpError raise HttpError(500, f"Download failed: {exc}") + + +@router.get("/{item_id}/progress", response=dict) +def download_progress(request, item_id: int): + """ + Return the current download progress string (e.g. '45.1%') from the yt-dlp hook. + """ + from django.core.cache import cache + item = get_object_or_404(MediaItem, id=item_id) + if not item.youtube_video_id: + from ninja.errors import HttpError + raise HttpError(400, "MediaItem is not a YouTube video.") + + if item.cached_file_path: + return {"status": "finished", "progress": "100%"} + + pct = cache.get(f"yt_progress_{item.youtube_video_id}") + return {"status": "downloading" if pct else "unknown", "progress": pct or "0%"} diff --git a/assets/TestCard.svg b/assets/TestCard.svg new file mode 100644 index 0000000..d0d7c54 --- /dev/null +++ b/assets/TestCard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/migrations/0004_channel_fallback_collection.py b/core/migrations/0004_channel_fallback_collection.py new file mode 100644 index 0000000..3d3cc39 --- /dev/null +++ b/core/migrations/0004_channel_fallback_collection.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.3 on 2026-03-09 12:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0003_channelsourcerule_schedule_block_label_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="channel", + name="fallback_collection", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fallback_for_channels", + to="core.mediacollection", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 1d444df..4c54081 100644 --- a/core/models.py +++ b/core/models.py @@ -203,6 +203,7 @@ class Channel(models.Model): owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE) library = models.ForeignKey(Library, on_delete=models.CASCADE) default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True) + fallback_collection = models.ForeignKey('MediaCollection', on_delete=models.SET_NULL, blank=True, null=True, related_name='fallback_for_channels') name = models.CharField(max_length=255) slug = models.SlugField(max_length=64, unique=True) channel_number = models.IntegerField(blank=True, null=True) diff --git a/core/services/scheduler.py b/core/services/scheduler.py index 830678b..4a53144 100644 --- a/core/services/scheduler.py +++ b/core/services/scheduler.py @@ -41,10 +41,18 @@ class ScheduleGenerator: Idempotent generation of airings for `target_date`. Returns the number of new Airing rows created. """ + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + template = self._get_template() if not template: return 0 + # Resolve the template's local timezone (fall back to UTC) + try: + local_tz = ZoneInfo(template.timezone_name or 'UTC') + except (ZoneInfoNotFoundError, Exception): + local_tz = ZoneInfo('UTC') + target_weekday_bit = 1 << target_date.weekday() blocks = template.scheduleblock_set.all().order_by('start_local_time') airings_created = 0 @@ -53,10 +61,14 @@ class ScheduleGenerator: if not (block.day_of_week_mask & target_weekday_bit): continue - start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc) - end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc) + # Convert local block times to UTC-aware datetimes + start_local = datetime.combine(target_date, block.start_local_time, tzinfo=local_tz) + end_local = datetime.combine(target_date, block.end_local_time, tzinfo=local_tz) - # Midnight-wrap support (e.g. 23:00–02:00) + start_dt = start_local.astimezone(timezone.utc) + end_dt = end_local.astimezone(timezone.utc) + + # Midnight-wrap support (e.g. 23:00–02:00 local) if end_dt <= start_dt: end_dt += timedelta(days=1) @@ -81,12 +93,18 @@ class ScheduleGenerator: if latest_prior_airing and latest_prior_airing.ends_at > start_dt: actual_start_dt = latest_prior_airing.ends_at + # If the prior block ran all the way through this block's window, skip + if actual_start_dt >= end_dt: + continue + airings_created += self._fill_block( template, block, actual_start_dt, end_dt, available_items ) return airings_created + + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -248,13 +266,21 @@ class ScheduleGenerator: logger = logging.getLogger(__name__) for original_airing in airings: - # 1. Fetch available downloaded items for this block - safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True) - if not safe_items: - logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for block {original_airing.schedule_block.name}") - continue + # 1. First check if the channel has a dedicated error fallback collection + safe_items = [] + if getattr(self.channel, 'fallback_collection', None): + safe_items = list(self.channel.fallback_collection.media_items.exclude( + cached_file_path__isnull=True, + media_source__source_type__in=['youtube', 'youtube_channel', 'youtube_playlist'] + )) + # 2. If no fallback collection or it yielded no valid items, try block sources + if not safe_items: + safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True) + if not safe_items: + logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for fallback or block {original_airing.schedule_block.name}") + continue # 2. Pick a random valid fallback item fallback_item = random.choice(safe_items) old_duration = original_airing.ends_at - original_airing.starts_at diff --git a/core/services/youtube.py b/core/services/youtube.py index 67a7376..706a3ec 100644 --- a/core/services/youtube.py +++ b/core/services/youtube.py @@ -207,6 +207,21 @@ def download_for_airing(media_item: MediaItem) -> Path: logger.info("cache hit: %s already at %s", video_id, existing) return existing + from django.core.cache import cache + + def progress_hook(d): + if d['status'] == 'downloading': + # Note: _percent_str includes ANSI escape codes sometimes, but yt_dlp usually cleans it if not a tty + pct = d.get('_percent_str', '').strip() + # Clean ANSI just in case + import re + pct_clean = re.sub(r'\x1b\[[0-9;]*m', '', pct).strip() + if pct_clean: + # Store the string "xx.x%" into Django cache. Expire after 5 mins so it cleans itself up. + cache.set(f"yt_progress_{video_id}", pct_clean, timeout=300) + elif d['status'] == 'finished': + cache.set(f"yt_progress_{video_id}", "100%", timeout=300) + ydl_opts = { "quiet": True, "no_warnings": True, @@ -218,6 +233,7 @@ def download_for_airing(media_item: MediaItem) -> Path: # 3. Any pre-muxed webm # 4. Anything pre-muxed (no merger needed) "format": "best[ext=mp4][height<=1080]/best[ext=mp4]/best[ext=webm]/best", + "progress_hooks": [progress_hook], } url = media_item.file_path # URL stored here by sync_source diff --git a/db.sqlite3 b/db.sqlite3 index dc12785fc5eba3ce300a89786488d338fbceb6a0..8086e5cadf04a7a9cbaa9d09f12ff6e596a217d6 100644 GIT binary patch delta 813 zcmaKqOH30{6o%(c@65C_eTdLjAjY9cOcS6pEswSdfd=D(0AYc_nBa8Uj-|0op_W9! zL`Gstf(Q)>mqZr?T^hAPnFSFwvLl94)aZhM8x}5HvM@Xx7d2fNZ}Km0a_;w^bM8O> z&N05{n5`_4Fbtd0R5&}CS`U!2!KW6Tjc%BuPB|fcn{Id#VCmj=8-ZE70^vkb;yA8B z2oH#|EX9TEVmuxa!#9L*A|98*saQgGbMB@(&Qr(vSl;dTH2Jy4dbiijbG|;S1{o

o3d8y4`-R+281{Z))^FNL&zyfP6k+&6YGukb80%Cs>gJ!IH2T%;zb7IHyr zCXF-cutHPzni?=;K$%DQS+(U0e1RJrg}tJaIXr|~HwY^_`3f_tHwOnHihhRA)tPUw z69QTVLqNyAhQ!g^D$U%4?O+y%@xb_uIWZg>Ug{6@-Nc?{jcCVq;KR89yUL{Nlyj8b z=L6FtXu22j>ize`U3^}EG*k2kXgEt+5nLxe>Uf1k-&e>8YCA)i(SvfL6#XnGYSr92 zv1>pZpWx&4+9si{ZIYLAy3=4AQHxYH4z@M79`(*5)dxD1dZmrBOEtLCACn`}tv^Bf zUxNtwFP5*v)1avl5c9J4NJdX>DZaq#`1s`WPif z-Y`1R4fC6z24+QmKcdUJ(FIcWBd|qD{mP&9Gps(dAm|((IPg5@aGrBcjd@Q!^0vF} zHH46}I{wCJ$GnBK=2j$W6`gXr6KsJyWu9*NPCDnOlR6XWU{g9sbqG2{EgfV*&kB|x z24*0VCWV**K3e1Ki)pcZY${nor~iwl&D+5h2+-Crj`c$UU5}fqShi-pCAEpDRI}p@ zmXjzpxOoLWx-1y6YDkiCWr|B$P7dEhwRl7nDKo@~ij{-XD!KuSqGC2$V& z?E@c{M#KV~Rn4M|5W#`F`(QQJ_Cg*$?1OTd5L)Nj7@$fKrh37NzJBmv%b=7uSxv)3 z{ovF4$SAIR#xLq>zQDD!m@4H+Uvf7-c)@Lk+W5hJyX*Ju#p+$`p7z|$OBn7CvyPz% zqm4^g53!SCVp(RsX8K`@$|>1zY?G$*48P7}%qlhtpQ)F;*CoG`y(Zh}SE5~f%Nm$$ z$&knYZr2{+2J{Z|D=1~yfa%N;MFyAOPg{c6aaqXKM$_!3aAA}=a5%0jZEsmKo2H0> zg_nd9?OTRfnC?OF|M4)Uf*Y@@V8dD!rqc$b4b*|>RH)EB(-Zhkg{^p01wVdQAs6FG zSd7j(?!<8w?D#4Pb8voAXZ6Z~g*`Br${KB?qUSE-6Ayh^H;ILuCswaHVGfzj8JmqO z3<=2adg}u=LMwEtxTDO0mLVyOtxx>R_*(2UjRWTM7>EsX?0J+1TdhGqjm0esa>6 z8Q(nNDQ)dD{=vxgw;TeD9S}<lFY1 diff --git a/docker-compose.yml b/docker-compose.yml index 3b728a1..50bfd13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: command: python manage.py runserver 0.0.0.0:8000 volumes: - .:/app + - ./mock:/mock + - ./cache:/tmp/pytv_cache ports: - "8000:8000" environment: @@ -26,5 +28,19 @@ services: depends_on: - db + cache_worker: + build: . + command: python manage.py run_cache_worker + volumes: + - .:/app + - ./mock:/mock + - ./cache:/tmp/pytv_cache + environment: + - DEBUG=True + - SECRET_KEY=django-insecure-development-key-replace-in-production + - DATABASE_URL=postgres://pytv_user:pytv_password@db:5432/pytv_db + depends_on: + - db + volumes: postgres_data: diff --git a/frontend/public/testcard.svg b/frontend/public/testcard.svg new file mode 100644 index 0000000..d0d7c54 --- /dev/null +++ b/frontend/public/testcard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js index fe28d86..23219d1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -59,9 +59,11 @@ export const fetchDownloadStatus = async () => (await apiClient.get('/sources/do export const triggerCacheUpcoming = async (hours = 24, pruneOnly = false) => (await apiClient.post(`/sources/cache-upcoming?hours=${hours}&prune_only=${pruneOnly}`)).data; export const downloadItem = async (itemId) => (await apiClient.post(`/sources/${itemId}/download`)).data; +export const fetchDownloadProgress = async (itemId) => (await apiClient.get(`/sources/${itemId}/progress`)).data; // ── Libraries ───────────────────────────────────────────────────────────── export const fetchLibraries = async () => (await apiClient.get('/library/')).data; +export const fetchCollections = async () => (await apiClient.get('/collections/')).data; // ── Users ───────────────────────────────────────────────────────────────── export const fetchUsers = async () => (await apiClient.get('/user/')).data; diff --git a/frontend/src/components/ChannelTuner.jsx b/frontend/src/components/ChannelTuner.jsx index fd9c2d0..fa17b09 100644 --- a/frontend/src/components/ChannelTuner.jsx +++ b/frontend/src/components/ChannelTuner.jsx @@ -2,11 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useRemoteControl } from '../hooks/useRemoteControl'; import { fetchChannels, fetchChannelNow } from '../api'; -const FALLBACK_VIDEOS = [ - 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', - 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4' -]; +const TEST_CARD_SRC = '/testcard.svg'; export default function ChannelTuner({ onOpenGuide }) { const [channels, setChannels] = useState([]); @@ -17,7 +13,6 @@ export default function ChannelTuner({ onOpenGuide }) { const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData } const osdTimerRef = useRef(null); - // The 3 buffer indices const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length; const getNextIndex = (index) => (index + 1) % channels.length; @@ -30,107 +25,114 @@ export default function ChannelTuner({ onOpenGuide }) { osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000); }, []); - const wrapChannelUp = () => { - setCurrentIndex(getNextIndex); - triggerOSD(); - }; - - const wrapChannelDown = () => { - setCurrentIndex(getPrevIndex); - triggerOSD(); - }; + const wrapChannelUp = () => { setCurrentIndex(getNextIndex); triggerOSD(); }; + const wrapChannelDown = () => { setCurrentIndex(getPrevIndex); triggerOSD(); }; useRemoteControl({ onChannelUp: wrapChannelUp, onChannelDown: wrapChannelDown, onSelect: triggerOSD, - onBack: onOpenGuide + onBack: onOpenGuide, }); - // Debug Info Toggle + // Debug toggle (i key) useEffect(() => { const handleKeyDown = (e) => { - // Ignore if user is typing in an input - if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; - if (e.key === 'i' || e.key === 'I') { - setShowDebug(prev => !prev); - } + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; + if (e.key === 'i' || e.key === 'I') setShowDebug(prev => !prev); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); - // Fetch channels from Django API + // Fetch channels — always sorted by channel_number (API guarantees this; sort defensively here too) useEffect(() => { - fetchChannels().then(data => { - const mapped = data.map((ch, idx) => ({ - ...ch, - fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length] - })); - if (mapped.length === 0) { - mapped.push({ id: 99, channel_number: '99', name: 'Default Feed', fallbackFile: FALLBACK_VIDEOS[0] }); - } - setChannels(mapped); - setLoading(false); - }).catch(err => { - console.error(err); - setChannels([{ id: 99, channel_number: '99', name: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]); - setLoading(false); - }); + fetchChannels() + .then(data => { + const sorted = [...data].sort((a, b) => { + if (a.channel_number == null) return 1; + if (b.channel_number == null) return -1; + return a.channel_number - b.channel_number; + }); + setChannels(sorted.length > 0 + ? sorted + : [{ id: 99, channel_number: 99, name: 'Default Feed' }] + ); + setLoading(false); + }) + .catch(err => { + console.error(err); + setChannels([{ id: 99, channel_number: 99, name: 'Offline' }]); + setLoading(false); + }); }, []); - // Fetch "Now Playing" metadata for the current channel group whenever currentIndex changes + // Fetch "Now Playing" for active triple-buffer useEffect(() => { if (channels.length === 0) return; - - const activeIndices = [currentIndex, prevIndex, nextIndex]; - activeIndices.forEach(idx => { + [currentIndex, prevIndex, nextIndex].forEach(idx => { const chan = channels[idx]; - fetchChannelNow(chan.id).then(data => { - setNowPlaying(prev => ({ ...prev, [chan.id]: data })); - }).catch(() => { - setNowPlaying(prev => ({ ...prev, [chan.id]: null })); - }); + if (!chan) return; + fetchChannelNow(chan.id) + .then(data => setNowPlaying(prev => ({ ...prev, [chan.id]: data }))) + .catch(() => setNowPlaying(prev => ({ ...prev, [chan.id]: null }))); }); }, [currentIndex, channels, prevIndex, nextIndex]); - // Initial OSD hide + // Initial OSD useEffect(() => { if (!loading) triggerOSD(); return () => clearTimeout(osdTimerRef.current); }, [loading, triggerOSD]); if (loading || channels.length === 0) { - return

Connecting to PYTV Backend...
; + return ( +
+ Test Card +
+ ); } const currentChan = channels[currentIndex]; const airing = nowPlaying[currentChan.id]; + /** Build the video source for a channel, or null if nothing playable. */ + function getVideoSrc(chan) { + const currentAiring = nowPlaying[chan.id]; + if (!currentAiring || !currentAiring.media_item_path) return null; + const path = currentAiring.media_item_path; + if (path.startsWith('http')) return path; + const clean = path.replace(/^\/?(?:media)?\/*/,''); + return `/media/${clean}`; + } + + return (
{channels.map((chan, index) => { const isCurrent = index === currentIndex; - const isPrev = index === prevIndex; - const isNext = index === nextIndex; - + const isPrev = index === prevIndex; + const isNext = index === nextIndex; if (!isCurrent && !isPrev && !isNext) return null; - let stateClass = 'buffering'; - if (isCurrent) stateClass = 'playing'; + const stateClass = isCurrent ? 'playing' : 'buffering'; + const videoSrc = getVideoSrc(chan); - // Use the current airing's media item file if available, else fallback - const currentAiring = nowPlaying[chan.id]; - let videoSrc = chan.fallbackFile; - if (currentAiring && currentAiring.media_item_path) { - if (currentAiring.media_item_path.startsWith('http')) { - videoSrc = currentAiring.media_item_path; - } else { - // Django serves cached media at root, Vite proxies /media to root - // Remove leading slashes or /media/ to avoid double slashes like /media//mock - const cleanPath = currentAiring.media_item_path.replace(/^\/?(media)?\/*/, ''); - videoSrc = `/media/${cleanPath}`; - } + // If no video available, show the test card image for this slot + if (!videoSrc) { + return ( +
+ No Signal +
+ ); } return ( @@ -138,33 +140,30 @@ export default function ChannelTuner({ onOpenGuide }) { key={chan.id} src={videoSrc} className={`tuner-video ${stateClass}`} - autoPlay={true} + autoPlay muted={!isCurrent} loop playsInline onLoadedMetadata={(e) => { const video = e.target; - if (currentAiring && currentAiring.starts_at) { - const startTime = new Date(currentAiring.starts_at).getTime(); - const nowTime = Date.now(); - - if (nowTime > startTime) { - const offsetSeconds = (nowTime - startTime) / 1000; - // If the video is shorter than the offset (e.g. repeating a short clip), - // modulo the offset by duration to emulate a continuous loop. - if (video.duration && video.duration > 0) { - video.currentTime = offsetSeconds % video.duration; - } else { - video.currentTime = offsetSeconds; - } + const currentAiring = nowPlaying[chan.id]; + if (currentAiring?.starts_at) { + const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000; + if (offsetSeconds > 0 && video.duration > 0) { + video.currentTime = offsetSeconds % video.duration; } } }} onError={(e) => { - if (e.target.src !== chan.fallbackFile) { - console.warn(`Video failed to load: ${e.target.src}, falling back.`); - e.target.src = chan.fallbackFile; - } + // Replace video with test card on error + console.warn(`Video failed to load: ${e.target.src} — showing test card`); + const parent = e.target.parentNode; + if (!parent) return; + const img = document.createElement('img'); + img.src = TEST_CARD_SRC; + img.alt = 'No Signal'; + img.style.cssText = 'width:100%;height:100%;object-fit:contain;position:absolute;top:0;left:0;background:#000'; + e.target.replaceWith(img); }} /> ); @@ -177,21 +176,15 @@ export default function ChannelTuner({ onOpenGuide }) {
Channel: {currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id}) - + Airing ID: {airing ? airing.id : 'N/A'} - + Media Item: - {airing && airing.media_item_title ? airing.media_item_title : 'N/A'} - + {airing?.media_item_title ?? 'N/A'} + Stream URL: - {(() => { - if (airing && airing.media_item_path) { - if (airing.media_item_path.startsWith('http')) return airing.media_item_path; - return `/media/${airing.media_item_path.replace(/^\/?(media)?\/*/, '')}`; - } - return currentChan.fallbackFile; - })()} + {getVideoSrc(currentChan) ?? '(test card — no video)'}
)} @@ -200,7 +193,7 @@ export default function ChannelTuner({ onOpenGuide }) {
- {currentChan.channel_number} + {currentChan.channel_number} {currentChan.name}
@@ -208,20 +201,20 @@ export default function ChannelTuner({ onOpenGuide }) {
- {airing ? airing.slot_kind.toUpperCase() : 'LIVE'} + {airing ? airing.slot_kind?.toUpperCase() : 'NO SIGNAL'} {airing && ( - {new Date(airing.starts_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} - - {new Date(airing.ends_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} + {new Date(airing.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} –{' '} + {new Date(airing.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} )}

- {airing ? airing.media_item_title : 'Loading Program...'} + {airing ? airing.media_item_title : '— No Programming —'}

- Watching {currentChan.name}. Press Escape to load the EPG Guide. + Watching {currentChan.name}. Press Escape for the EPG Guide.

diff --git a/frontend/src/components/Guide.jsx b/frontend/src/components/Guide.jsx index 572d791..d7d208f 100644 --- a/frontend/src/components/Guide.jsx +++ b/frontend/src/components/Guide.jsx @@ -1,10 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useRemoteControl } from '../hooks/useRemoteControl'; import { fetchChannels, fetchChannelAirings } from '../api'; -// Hours to show in the EPG and width configs const EPG_HOURS = 4; -const HOUR_WIDTH_PX = 360; +const HOUR_WIDTH_PX = 360; const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000); function fmtTime(iso) { @@ -12,26 +11,88 @@ function fmtTime(iso) { return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } +function fmtDuration(starts_at, ends_at) { + if (!starts_at || !ends_at) return ''; + const mins = Math.round((new Date(ends_at) - new Date(starts_at)) / 60000); + if (mins < 60) return `${mins}m`; + const h = Math.floor(mins / 60), m = mins % 60; + return m ? `${h}h ${m}m` : `${h}h`; +} + export default function Guide({ onClose, onSelectChannel }) { const [channels, setChannels] = useState([]); - const [airings, setAirings] = useState({}); // { channelId: [airing, ...] } - const [selectedIndex, setSelectedIndex] = useState(0); + const [airings, setAirings] = useState({}); + const [selectedRow, setSelectedRow] = useState(0); + const [selectedProgram, setSelectedProgram] = useState(0); const [now, setNow] = useState(Date.now()); const scrollRef = useRef(null); + const programRefs = useRef({}); // { `${rowIdx}-${progIdx}`: el } + const rowsScrollRef = useRef(null); - // Time anchor: align to previous 30-min boundary for clean axis const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime()); + // Build filtered + sorted airing list for a channel + const getAiringsForRow = useCallback((chanId) => { + const list = airings[chanId] || []; + return list + .filter(a => new Date(a.ends_at).getTime() > anchorTime.current) + .sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at)); + }, [airings]); + + // Clamp selectedProgram when row changes + useEffect(() => { + const ch = channels[selectedRow]; + if (!ch) return; + const rowAirings = getAiringsForRow(ch.id); + setSelectedProgram(prev => Math.min(prev, Math.max(0, rowAirings.length - 1))); + }, [selectedRow, channels, getAiringsForRow]); + + // Auto-scroll focused program into view (timeline scroll) + useEffect(() => { + const key = `${selectedRow}-${selectedProgram}`; + const el = programRefs.current[key]; + if (el && scrollRef.current) { + const containerRect = scrollRef.current.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const relLeft = elRect.left - containerRect.left + scrollRef.current.scrollLeft; + const targetScroll = relLeft - containerRect.width / 4; + scrollRef.current.scrollTo({ left: Math.max(0, targetScroll), behavior: 'smooth' }); + } + }, [selectedRow, selectedProgram]); + + // Auto-scroll focused row into view (rows column scroll) + useEffect(() => { + if (rowsScrollRef.current) { + const rowHeight = 80; + const visibleHeight = rowsScrollRef.current.clientHeight; + const rowTop = selectedRow * rowHeight; + const rowBottom = rowTop + rowHeight; + const currentScroll = rowsScrollRef.current.scrollTop; + if (rowTop < currentScroll) { + rowsScrollRef.current.scrollTop = rowTop; + } else if (rowBottom > currentScroll + visibleHeight) { + rowsScrollRef.current.scrollTop = rowBottom - visibleHeight; + } + } + }, [selectedRow]); + useRemoteControl({ - onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length), - onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length), - onSelect: () => channels[selectedIndex] && onSelectChannel(channels[selectedIndex].id), - onBack: onClose, + onUp: () => setSelectedRow(prev => Math.max(0, prev - 1)), + onDown: () => setSelectedRow(prev => Math.min(channels.length - 1, prev + 1)), + onLeft: () => setSelectedProgram(prev => Math.max(0, prev - 1)), + onRight: () => { + const ch = channels[selectedRow]; + if (!ch) return; + const rowAirings = getAiringsForRow(ch.id); + setSelectedProgram(prev => Math.min(rowAirings.length - 1, prev + 1)); + }, + onSelect: () => channels[selectedRow] && onSelectChannel(channels[selectedRow].id), + onBack: onClose, }); // Clock tick useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh + const timer = setInterval(() => setNow(Date.now()), 15000); return () => clearInterval(timer); }, []); @@ -39,13 +100,17 @@ export default function Guide({ onClose, onSelectChannel }) { useEffect(() => { fetchChannels() .then(data => { - if (!data || data.length === 0) { + const sorted = [...data].sort((a, b) => { + if (a.channel_number == null) return 1; + if (b.channel_number == null) return -1; + return a.channel_number - b.channel_number; + }); + if (sorted.length === 0) { setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]); return; } - setChannels(data); + setChannels(sorted); - // Fetch overlapping data for timeline Promise.allSettled( data.map(ch => fetchChannelAirings(ch.id, EPG_HOURS) @@ -55,61 +120,77 @@ export default function Guide({ onClose, onSelectChannel }) { ).then(results => { const map = {}; for (const r of results) { - if (r.status === 'fulfilled') { - map[r.value.channelId] = r.value.list; - } + if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list; } setAirings(map); }); }) - .catch((err) => { - console.error("Guide fetch error:", err); + .catch(err => { + console.error('Guide fetch error:', err); setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]); }); }, []); - // Auto-scroll the timeline to keep the playhead visible but leave - // ~0.5 hours of padding on the left + // Initial scroll to now useEffect(() => { if (scrollRef.current) { const msOffset = now - anchorTime.current; const pxOffset = msOffset * PX_PER_MS; - // scroll left to (current time - 30 mins) - const targetScroll = Math.max(0, pxOffset - (HOUR_WIDTH_PX / 2)); - scrollRef.current.scrollLeft = targetScroll; + scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2); } - }, [now, airings]); + }, [airings]); // only run once airings load const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const playheadPx = (now - anchorTime.current) * PX_PER_MS; + const containerWidthPx = EPG_HOURS * HOUR_WIDTH_PX + HOUR_WIDTH_PX; - // Generate grid time slots (half-hour chunks) const timeSlots = Array.from({ length: EPG_HOURS * 2 + 2 }).map((_, i) => { - const ts = new Date(anchorTime.current + (i * 30 * 60 * 1000)); + const ts = new Date(anchorTime.current + i * 30 * 60 * 1000); return { label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), - left: i * (HOUR_WIDTH_PX / 2) + left: i * (HOUR_WIDTH_PX / 2), }; }); - // Background pattern width - const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX; + // Focused program info for the detail panel + const focusedChannel = channels[selectedRow]; + const focusedAirings = focusedChannel ? getAiringsForRow(focusedChannel.id) : []; + const focusedAiring = focusedAirings[selectedProgram] || null; + + const nowStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const isLive = focusedAiring + ? now >= new Date(focusedAiring.starts_at).getTime() && now < new Date(focusedAiring.ends_at).getTime() + : false; + + // Progress % for live program + const liveProgress = isLive && focusedAiring + ? Math.min(100, Math.round( + (now - new Date(focusedAiring.starts_at).getTime()) / + (new Date(focusedAiring.ends_at).getTime() - new Date(focusedAiring.starts_at).getTime()) * 100 + )) + : 0; return ( -
+
+ {/* Header */}

PYTV Guide

{currentTimeStr}
-
- - {/* Left fixed column for Channels */} -
-
-
+ {/* EPG grid — takes available space between header and detail panel */} +
+ + {/* Left sticky channel column */} +
+
+
{channels.map((chan, idx) => ( -
+
{ setSelectedRow(idx); setSelectedProgram(0); }} + >
{chan.channel_number}
{chan.name}
@@ -117,13 +198,13 @@ export default function Guide({ onClose, onSelectChannel }) {
- {/* Scrollable Timeline */} + {/* Scrollable timeline */}
-
- {/* Time Axis Row */} + {/* Time axis */}
{timeSlots.map((slot, i) => (
@@ -132,32 +213,31 @@ export default function Guide({ onClose, onSelectChannel }) { ))}
- {/* Live Playhead Line */} + {/* Live playhead */} {playheadPx > 0 && playheadPx < containerWidthPx && (
)} - {/* Grid Rows for Airings */} + {/* Program rows */}
- {channels.map((chan, idx) => { - const chanAirings = airings[chan.id]; - const isLoading = !chanAirings && chan.id !== 99; + {channels.map((chan, rowIdx) => { + const rowAirings = getAiringsForRow(chan.id); + const isLoading = !airings[chan.id] && chan.id !== 99; + const isActiveRow = rowIdx === selectedRow; return ( -
- {isLoading &&
Loading...
} - {!isLoading && chanAirings?.length === 0 && ( +
+ {isLoading &&
Loading…
} + {!isLoading && rowAirings.length === 0 && (
No scheduled programs
)} - {chanAirings && chanAirings.map(a => { + {rowAirings.map((a, progIdx) => { const sTs = new Date(a.starts_at).getTime(); const eTs = new Date(a.ends_at).getTime(); - - // Filter anything that ended before our timeline anchor - if (eTs <= anchorTime.current) return null; - - // Calculate block dimensions const startPx = Math.max(0, (sTs - anchorTime.current) * PX_PER_MS); const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS; const endPx = Math.min(containerWidthPx, rawEndPx); @@ -165,17 +245,31 @@ export default function Guide({ onClose, onSelectChannel }) { let stateClass = 'future'; if (now >= sTs && now < eTs) stateClass = 'current'; - if (now >= eTs) stateClass = 'past'; + else if (now >= eTs) stateClass = 'past'; + + const isFocused = isActiveRow && progIdx === selectedProgram; return ( -
{ + const key = `${rowIdx}-${progIdx}`; + if (el) programRefs.current[key] = el; + }} + className={`epg-program ${stateClass}${isFocused ? ' epg-program-focused' : ''}`} style={{ left: `${startPx}px`, width: `${widthPx}px` }} - onClick={() => onSelectChannel(chan.id)} + onClick={() => { + setSelectedRow(rowIdx); + setSelectedProgram(progIdx); + onSelectChannel(chan.id); + }} + onMouseEnter={() => { + setSelectedRow(rowIdx); + setSelectedProgram(progIdx); + }} >
{a.media_item_title}
-
{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}
+
{fmtTime(a.starts_at)} – {fmtTime(a.ends_at)}
); })} @@ -185,11 +279,54 @@ export default function Guide({ onClose, onSelectChannel }) {
-
-
- Press Enter to tune · ↑↓ to navigate channels · Escape to close + {/* ── Detail panel ─────────────────────────────────────────────────── */} +
+ {focusedAiring ? ( + <> +
+ {isLive && ● LIVE} +
{focusedAiring.media_item_title || '—'}
+
+ {fmtTime(focusedAiring.starts_at)} – {fmtTime(focusedAiring.ends_at)} + · + {fmtDuration(focusedAiring.starts_at, focusedAiring.ends_at)} + {focusedChannel && ( + <> + · + CH {focusedChannel.channel_number} · {focusedChannel.name} + + )} +
+ {isLive && ( +
+
+
+ )} +
+
+
+ ↑↓ Channel   ←→ Program   Enter Watch   Esc Close +
+ {isLive && ( + + )} +
+ + ) : focusedChannel ? ( +
+
{focusedChannel.name}
+
No programs scheduled — ↑↓ to navigate channels
+
+ ) : ( +
+
↑↓ Channel   ←→ Program   Enter Watch
+
+ )}
); diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 9e4dc38..6dd40e8 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -6,8 +6,8 @@ import { fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday, fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock, fetchSources, createSource, syncSource, deleteSource, - fetchLibraries, - fetchDownloadStatus, triggerCacheUpcoming, downloadItem, + fetchLibraries, fetchCollections, + fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress, } from '../api'; // ─── Constants ──────────────────────────────────────────────────────────── @@ -57,6 +57,12 @@ function EmptyState({ text }) { return

{text}

; } +function timeToPct(timeStr) { + if (!timeStr) return 0; + const [h, m] = timeStr.split(':').map(Number); + return ((h * 60 + m) / 1440) * 100; +} + function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) { return (
@@ -398,7 +454,7 @@ function SourcesTab() { const [loading, setLoading] = useState(true); const [syncingId, setSyncingId] = useState(null); const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 }); + const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 }); const [feedback, setFeedback, ok, err] = useFeedback(); useEffect(() => { @@ -411,10 +467,14 @@ function SourcesTab() { e.preventDefault(); if (!form.library_id) { err('Please select a library.'); return; } try { - const src = await createSource({ ...form, library_id: parseInt(form.library_id) }); + const src = await createSource({ + ...form, + library_id: parseInt(form.library_id), + scan_interval_minutes: parseInt(form.scan_interval_minutes) || null + }); setSources(s => [...s, src]); setShowForm(false); - setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 }); + setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 }); ok(`Source "${src.name}" registered. Hit Sync to import videos.`); } catch { err('Failed to create source.'); } }; @@ -482,6 +542,11 @@ function SourcesTab() { onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} /> )} +
@@ -537,6 +602,7 @@ function DownloadsTab() { const [running, setRunning] = useState(false); const [runResult, setRunResult] = useState(null); const [downloadingId, setDownloadingId] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(null); // '45.1%' const [filterSource, setFilterSource] = useState('all'); const [feedback, setFeedback, ok, err] = useFeedback(); @@ -565,6 +631,15 @@ function DownloadsTab() { const handleDownloadOne = async (item) => { setDownloadingId(item.id); + setDownloadProgress('0%'); + let pollInterval = setInterval(async () => { + try { + const res = await fetchDownloadProgress(item.id); + if (res.progress) setDownloadProgress(res.progress); + if (res.status === 'finished') clearInterval(pollInterval); + } catch (e) { console.warn('Progress poll err', e); } + }, 1000); + try { await downloadItem(item.id); ok(`Downloaded "${item.title.slice(0, 50)}".`); @@ -575,7 +650,11 @@ function DownloadsTab() { })); } catch (e) { err(`Download failed: ${e?.response?.data?.detail || e.message}`); - } finally { setDownloadingId(null); } + } finally { + clearInterval(pollInterval); + setDownloadingId(null); + setDownloadProgress(null); + } }; const sources = status ? [...new Set(status.items.map(i => i.source_name))] : []; @@ -676,13 +755,17 @@ function DownloadsTab() { )}
+ {/* Inject a progress bar right under the item if it's currently downloading */} + {downloadingId === item.id && downloadProgress && ( +
+ )}
))}
@@ -846,6 +929,33 @@ function SchedulingTab() {
)} + {blocks.length > 0 && ( +
+ {/* Timeline tick marks */} + {[0, 6, 12, 18].map(h => ( +
+ {h}:00 +
+ ))} + {blocks.map(b => { + const startPct = timeToPct(b.start_local_time); + let endPct = timeToPct(b.end_local_time); + if (endPct <= startPct) endPct = 100; // spills past midnight visually + const width = endPct - startPct; + const color = b.block_type === 'OFF_AIR' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(96, 165, 250, 0.8)'; // red vs blue + return ( +
+ {width > 8 ? b.name : ''} +
+ ); + })} +
+ )} +
{blocks.map(b => (
diff --git a/frontend/src/index.css b/frontend/src/index.css index 8e2b4dd..a34ef47 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -472,6 +472,114 @@ body { font-style: italic; } +/* Keyboard/remote cursor focus ring on a program cell */ +.epg-program-focused { + outline: 2px solid var(--pytv-accent) !important; + outline-offset: -2px; + background: rgba(0, 212, 255, 0.20) !important; + box-shadow: 0 0 18px var(--pytv-accent-glow); + z-index: 4; +} +.epg-program-focused .epg-program-title { + color: #fff; +} + +/* ─── Detail Panel ──────────────────────────────────────────────────────── */ + +.epg-detail-panel { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + padding: 1.25rem 3rem; + background: rgba(10, 10, 20, 0.95); + border-top: 1px solid var(--pytv-glass-border); + min-height: 90px; +} + +.epg-detail-left { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; + flex: 1; +} + +.epg-detail-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.6rem; + flex-shrink: 0; +} + +.epg-live-badge { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 1.5px; + color: var(--pytv-accent); + text-shadow: 0 0 8px var(--pytv-accent-glow); + text-transform: uppercase; +} + +.epg-detail-title { + font-size: 1.35rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--pytv-text); +} + +.epg-detail-meta { + font-size: 0.85rem; + color: var(--pytv-text-dim); + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} + +.epg-detail-dot { + opacity: 0.4; +} + +/* Live progress bar under detail title */ +.epg-progress-bar { + height: 3px; + background: rgba(255,255,255,0.12); + border-radius: 2px; + overflow: hidden; + margin-top: 0.2rem; + width: min(400px, 100%); +} +.epg-progress-fill { + height: 100%; + background: var(--pytv-accent); + box-shadow: 0 0 6px var(--pytv-accent-glow); + border-radius: 2px; + transition: width 1s linear; +} + +/* Keyboard hints */ +.epg-detail-hint { + font-size: 0.76rem; + color: var(--pytv-text-dim); + opacity: 0.6; +} +.epg-detail-hint kbd { + display: inline-block; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.18); + border-radius: 4px; + padding: 0.05em 0.4em; + font-family: var(--font-osd); + font-size: 0.8em; + color: var(--pytv-text); +} + + /* Animations */ @keyframes slideUp { from { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..744f0e7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "pytv", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pytv", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ffprobe-static": "^3.1.0" + } + }, + "node_modules/ffprobe-static": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", + "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..54b664a --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "pytv", + "version": "1.0.0", + "description": "Simple app to replicate cable experience. Note this app has been developed with the heavy assistence of Generative AI as an experiment for myself. Therefore **it should not be considered secure**", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git@git.algebrist.com:tboudreaux/PYTV.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "ffprobe-static": "^3.1.0" + } +}