""" PodcastItLater Episode Detail Components. Components for displaying individual episode pages with media player, share functionality, and signup prompts for non-authenticated users. """ # : out podcastitlater-episode # : dep ludic import Biz.PodcastItLater.UI as UI import ludic.html as html import sys import typing from ludic.attrs import Attrs from ludic.components import Component from ludic.types import AnyChildren from typing import override class EpisodePlayerAttrs(Attrs): """Attributes for EpisodePlayer component.""" audio_url: str title: str episode_id: int class EpisodePlayer(Component[AnyChildren, EpisodePlayerAttrs]): """HTML5 audio player for episode playback.""" @override def render(self) -> html.div: audio_url = self.attrs["audio_url"] episode_id = self.attrs["episode_id"] return html.div( html.div( html.div( html.h5( html.i(classes=["bi", "bi-play-circle", "me-2"]), "Listen", classes=["card-title", "mb-3"], ), html.audio( html.source(src=audio_url, type="audio/mpeg"), "Your browser does not support the audio element.", controls=True, preload="metadata", id=f"audio-player-{episode_id}", classes=["w-100"], style={"max-width": "100%"}, ), # JavaScript to track play events html.script( f""" (function() {{ var player = document.getElementById( 'audio-player-{episode_id}' ); var hasTrackedPlay = false; player.addEventListener('play', function() {{ // Track first play only if (!hasTrackedPlay) {{ hasTrackedPlay = true; // Send play event to server fetch('/episode/{episode_id}/track', {{ method: 'POST', headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }}, body: 'event_type=played' }}); }} }}); }})(); """, ), classes=["card-body"], ), classes=["card", "mb-4"], ), ) class ShareButtonAttrs(Attrs): """Attributes for ShareButton component.""" share_url: str class ShareButton(Component[AnyChildren, ShareButtonAttrs]): """Button to copy episode URL to clipboard.""" @override def render(self) -> html.div: share_url = self.attrs["share_url"] return html.div( html.div( html.div( html.h5( html.i(classes=["bi", "bi-share", "me-2"]), "Share Episode", classes=["card-title", "mb-3"], ), html.div( html.div( html.button( html.i(classes=["bi", "bi-copy", "me-1"]), "Copy", type="button", id="share-button", on_click=f"navigator.clipboard.writeText('{share_url}'); " # noqa: E501 "const btn = document.getElementById('share-button'); " # noqa: E501 "const originalHTML = btn.innerHTML; " "btn.innerHTML = 'Copied!'; " # noqa: E501 "btn.classList.remove('btn-outline-secondary'); " # noqa: E501 "btn.classList.add('btn-success'); " "setTimeout(() => {{ " "btn.innerHTML = originalHTML; " "btn.classList.remove('btn-success'); " "btn.classList.add('btn-outline-secondary'); " "}}, 2000);", classes=["btn", "btn-outline-secondary"], ), html.input( type="text", value=share_url, readonly=True, on_focus="this.select()", classes=["form-control"], ), classes=["input-group"], ), ), classes=["card-body"], ), classes=["card"], ), classes=["mb-4"], ) class SignupBannerAttrs(Attrs): """Attributes for SignupBanner component.""" creator_email: str base_url: str class SignupBanner(Component[AnyChildren, SignupBannerAttrs]): """Banner prompting non-authenticated users to sign up.""" @override def render(self) -> html.div: creator_email = self.attrs["creator_email"] return html.div( html.div( html.div( html.div( html.i( classes=[ "bi", "bi-info-circle-fill", "me-2", ], ), html.strong("This episode was created by "), html.code( creator_email, classes=["text-dark"], ), html.strong(" using PodcastItLater."), classes=["mb-3"], ), html.div( html.p( "Want to convert your own articles " "to podcast episodes?", classes=["mb-2"], ), html.form( html.div( html.input( type="email", name="email", placeholder="Enter your email to start", required=True, classes=["form-control"], ), html.button( html.i( classes=[ "bi", "bi-arrow-right-circle", "me-2", ], ), "Sign Up", type="submit", classes=["btn", "btn-primary"], ), classes=["input-group"], ), hx_post="/login", hx_target="#signup-result", hx_swap="innerHTML", ), html.div(id="signup-result", classes=["mt-2"]), ), classes=["card-body"], ), classes=["card", "border-primary"], ), classes=["mb-4"], ) class EpisodeDetailPageAttrs(Attrs): """Attributes for EpisodeDetailPage component.""" episode: dict[str, typing.Any] episode_sqid: str creator_email: str | None user: dict[str, typing.Any] | None base_url: str user_has_episode: bool class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]): """Full page view for a single episode.""" @override def render(self) -> UI.PageLayout: episode = self.attrs["episode"] episode_sqid = self.attrs["episode_sqid"] creator_email = self.attrs.get("creator_email") user = self.attrs.get("user") base_url = self.attrs["base_url"] user_has_episode = self.attrs.get("user_has_episode", False) share_url = f"{base_url}/episode/{episode_sqid}" duration_str = UI.format_duration(episode.get("duration")) # Build page title page_title = f"{episode['title']} - PodcastItLater" # Build meta tags for Open Graph meta_tags = [ html.meta(property="og:title", content=episode["title"]), html.meta(property="og:type", content="website"), html.meta(property="og:url", content=share_url), html.meta( property="og:description", content=f"Listen to this article read aloud. " f"Duration: {duration_str}" + (f" by {episode['author']}" if episode.get("author") else ""), ), html.meta( property="og:site_name", content="PodcastItLater", ), html.meta(property="og:audio", content=episode["audio_url"]), html.meta(property="og:audio:type", content="audio/mpeg"), ] # Add Twitter Card tags meta_tags.extend([ html.meta(name="twitter:card", content="summary"), html.meta(name="twitter:title", content=episode["title"]), html.meta( name="twitter:description", content=f"Listen to this article. Duration: {duration_str}", ), ]) # Add author if available if episode.get("author"): meta_tags.append( html.meta(property="article:author", content=episode["author"]), ) return UI.PageLayout( # Show signup banner if user is not logged in SignupBanner( creator_email=creator_email or "a user", base_url=base_url, ) if not user and creator_email else html.div(), # Episode title and metadata html.div( html.h2( episode["title"], classes=["display-6", "mb-3"], ), html.div( html.span( html.i(classes=["bi", "bi-person", "me-1"]), f"by {episode['author']}", classes=["text-muted", "me-3"], ) if episode.get("author") else html.span(), html.span( html.i(classes=["bi", "bi-clock", "me-1"]), f"Duration: {duration_str}", classes=["text-muted", "me-3"], ), html.span( html.i(classes=["bi", "bi-calendar", "me-1"]), f"Created: {episode['created_at']}", classes=["text-muted"], ), classes=["mb-3"], ), html.div( html.a( html.i(classes=["bi", "bi-link-45deg", "me-1"]), "View original article", href=episode["original_url"], target="_blank", rel="noopener", classes=["btn", "btn-sm", "btn-outline-secondary"], ), ) if episode.get("original_url") else html.div(), classes=["mb-4"], ), # Audio player EpisodePlayer( audio_url=episode["audio_url"], title=episode["title"], episode_id=episode["id"], ), # Share button ShareButton(share_url=share_url), # Add to feed button (logged-in users without episode) html.div( html.div( html.div( html.h5( html.i(classes=["bi", "bi-plus-circle", "me-2"]), "Add to Your Feed", classes=["card-title", "mb-3"], ), html.p( "Save this episode to your personal feed " "to listen later.", classes=["text-muted", "mb-3"], ), html.button( html.i(classes=["bi", "bi-plus-lg", "me-1"]), "Add to My Feed", hx_post=f"/episode/{episode['id']}/add-to-feed", hx_target="#add-to-feed-result", hx_swap="innerHTML", classes=["btn", "btn-primary"], ), html.div(id="add-to-feed-result", classes=["mt-2"]), classes=["card-body"], ), classes=["card"], ), classes=["mb-4"], ) if user and not user_has_episode else html.div(), # Back to home link html.div( html.a( html.i(classes=["bi", "bi-arrow-left", "me-1"]), "Back to Home", href="/", classes=["btn", "btn-link"], ), classes=["mt-4"], ), user=user, current_page="", error=None, page_title=page_title, meta_tags=meta_tags, ) def main() -> None: """Episode module has no tests currently.""" if "test" in sys.argv: sys.exit(0)