diff --git a/README.md b/README.md index b290f19..7ca9d05 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ webdev serve --debug-extension or you use devtools [Debugging Web Info](https://dart.dev/web/debugging) +## Update the livestreams page + +After each stream, regenerate the episode list from the YouTube playlist and commit the result: + +```sh +dart run tool/fetch_livestreams.dart +``` + ## Build and Deploy ```sh diff --git a/lib/src/app/external_links.dart b/lib/src/app/external_links.dart index ba3623d..c7c16d1 100644 --- a/lib/src/app/external_links.dart +++ b/lib/src/app/external_links.dart @@ -15,6 +15,7 @@ enum ExternalLink { socialMastodon('https://fluttercommunity.social/@FlutterComm'), socialGitHub('https://github.com/fluttercommunity/fc_ai_circle'), socialMedium('https://medium.com/flutter-community'), + sessionizeCallForSpeakers('https://sessionize.com/fcaic'), ; const ExternalLink(this.url); diff --git a/lib/src/app/website.dart b/lib/src/app/website.dart index a244a43..eb75d8b 100644 --- a/lib/src/app/website.dart +++ b/lib/src/app/website.dart @@ -1,4 +1,5 @@ import 'package:fc_ai_circle/src/pages/builders_page.dart'; +import 'package:fc_ai_circle/src/pages/livestreams_page.dart'; import 'package:fc_ai_circle/src/pages/home_page.dart'; import 'package:fc_ai_circle/src/pages/starters_page.dart'; import 'package:fc_ai_circle/src/pages/privacy_policy_page.dart'; @@ -30,6 +31,7 @@ class _SiteState extends State { ...HomePage.route(), ...StartersPage.route(), ...BuildersPage.route(), + ...LivestreamsPage.route(), ...PrivacyPolicyPage.route(), ...CodeOfConductPage.route(), ], diff --git a/lib/src/components/footer.dart b/lib/src/components/footer.dart index dd9b10b..f9259af 100644 --- a/lib/src/components/footer.dart +++ b/lib/src/components/footer.dart @@ -2,6 +2,7 @@ import 'package:fc_ai_circle/src/app/external_links.dart'; import 'package:jaspr/browser.dart'; import 'package:jaspr_router/jaspr_router.dart'; import 'package:fc_ai_circle/src/pages/builders_page.dart'; +import 'package:fc_ai_circle/src/pages/livestreams_page.dart'; import 'package:fc_ai_circle/src/pages/starters_page.dart'; import 'package:fc_ai_circle/src/pages/privacy_policy_page.dart'; import 'package:fc_ai_circle/src/pages/code_of_conduct_page.dart'; @@ -25,6 +26,7 @@ class Footer extends StatelessComponent { links: [ (path: StartersPage.path, label: 'Starters'), (path: BuildersPage.path, label: 'Builders'), + (path: LivestreamsPage.path, label: 'Livestreams'), ], ), div([]), diff --git a/lib/src/components/navbar.dart b/lib/src/components/navbar.dart index 70a15ff..f67d477 100644 --- a/lib/src/components/navbar.dart +++ b/lib/src/components/navbar.dart @@ -4,6 +4,7 @@ import 'package:jaspr/browser.dart'; import 'package:jaspr_router/jaspr_router.dart' show Router; import 'package:fc_ai_circle/src/pages/builders_page.dart'; import 'package:fc_ai_circle/src/pages/home_page.dart'; +import 'package:fc_ai_circle/src/pages/livestreams_page.dart'; import 'package:fc_ai_circle/src/pages/starters_page.dart'; import 'package:web/web.dart' show window; @@ -71,6 +72,7 @@ class _NavbarState extends State { _NavItem(path: '/', label: 'Home'), _NavItem(path: StartersPage.path, label: 'Starters'), _NavItem(path: BuildersPage.path, label: 'Builders'), + _NavItem(path: LivestreamsPage.path, label: 'Livestreams'), _NavItem(link: ExternalLink.youTubePlaylist, label: 'YouTube'), _NavItem(link: ExternalLink.socialGitHub, label: 'GitHub'), _NavItem(link: ExternalLink.forumCategory, label: 'Forum'), diff --git a/lib/src/data/livestream.dart b/lib/src/data/livestream.dart new file mode 100644 index 0000000..7d21338 --- /dev/null +++ b/lib/src/data/livestream.dart @@ -0,0 +1,46 @@ +/// One episode of the FCAIC livestream on the Flutter Community YouTube channel. +class Livestream { + const Livestream({ + required this.videoId, + required this.number, + required this.topic, + required this.length, + required this.date, + }); + + final String videoId; + final int number; + final String topic; + + /// Duration as YouTube shows it, e.g. `1:02:13`. + final String length; + + /// Stream start date in Europe/Berlin, ISO 8601 (`yyyy-MM-dd`). + final String date; + + static const playlistId = 'PL4dBIh1xps-HIYvaEIbLWHZqt_WGBfpx3'; + + String get year => date.substring(0, 4); + + String get watchUrl => 'https://www.youtube.com/watch?v=$videoId&list=$playlistId'; + + String thumbnailUrl({bool large = false}) => + 'https://i.ytimg.com/vi/$videoId/${large ? 'maxresdefault' : 'hqdefault'}.jpg'; + + int get lengthInSeconds { + const secondsPerUnit = 60; + return length + .split(':') + .fold(0, (total, part) => total * secondsPerUnit + (int.tryParse(part) ?? 0)); + } + + static const _months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', // + ]; + + String get monthYear => '${_months[int.parse(date.substring(5, 7)) - 1]} $year'; + + String get prettyDate => '${int.parse(date.substring(8, 10))} $monthYear'; + + bool matches(String query) => '#$number $topic'.toLowerCase().contains(query); +} diff --git a/lib/src/data/livestreams.g.dart b/lib/src/data/livestreams.g.dart new file mode 100644 index 0000000..03d2048 --- /dev/null +++ b/lib/src/data/livestreams.g.dart @@ -0,0 +1,209 @@ +// Generated by tool/fetch_livestreams.dart. Do not edit by hand. + +import 'package:fc_ai_circle/src/data/livestream.dart'; + +const livestreams = [ + Livestream( + videoId: '7iB8U8nKyDI', + number: 29, + topic: 'Stakeholder-Ready Prototypes & Video Editor in Flutter', + length: '58:12', + date: '2026-09-22', + ), + Livestream( + videoId: 'zaYVbkGpays', + number: 28, + topic: 'Bridging the SDK Version Gap with Agent Skills', + length: '1:29:40', + date: '2026-09-01', + ), + Livestream( + videoId: 'NxK5lOzq9Mc', + number: 27, + topic: 'Adventure-GM & Randal’s Ticket Resolution Workflow', + length: '1:02:37', + date: '2026-08-11', + ), + Livestream( + videoId: 'cWjoY-zBz2g', + number: 26, + topic: 'Building and Caching A2UI Grammar Lessons with Flutter & Firebase', + length: '1:49:19', + date: '2026-07-21', + ), + Livestream( + videoId: 'xM7Q0_dnOs8', + number: 25, + topic: 'AI-Assisted E2E Testing in Flutter with Patrol MCP', + length: '1:18:10', + date: '2026-06-23', + ), + Livestream( + videoId: 'NPz4lyP1WQI', + number: 24, + topic: 'On-device RAG Redefined: Production-Ready Mobile AI', + length: '1:01:33', + date: '2026-06-09', + ), + Livestream( + videoId: 'dfo6FS814D0', + number: 23, + topic: 'Flutter Updates + Agentic Coding Toolkit w/ Andrea', + length: '1:18:58', + date: '2026-05-26', + ), + Livestream( + videoId: 'yX_stItg0lI', + number: 22, + topic: 'Installing and Using Agent Skills for Dart & Flutter', + length: '1:03:23', + date: '2026-05-12', + ), + Livestream( + videoId: 'wVi_EJPsB7Y', + number: 21, + topic: 'Flutter Updates from Google Cloud Next 2026', + length: '53:08', + date: '2026-04-28', + ), + Livestream( + videoId: 'SU6d_r3o-gY', + number: 20, + topic: 'Build full-stack, agentic apps with Genkit Dart with Chris Gill', + length: '1:01:54', + date: '2026-04-14', + ), + Livestream( + videoId: '8RHsCQIFx4M', + number: 19, + topic: 'Unlocking On-Device AI with llamadart', + length: '1:09:11', + date: '2026-03-31', + ), + Livestream( + videoId: 'UcasNRoYbcU', + number: 18, + topic: 'The Architect’s Dilemma: Navigating Customization in Google Antigravity and Gemini CLI', + length: '1:12:00', + date: '2026-03-03', + ), + Livestream( + videoId: '0QWOYTxjL8Y', + number: 17, + topic: 'QuAI: The AI Writing Assistant One Can Use Every Day', + length: '57:21', + date: '2026-02-17', + ), + Livestream( + videoId: '6tWaOmzSodQ', + number: 16, + topic: 'A Flutter-first coding agent built on Claude Code', + length: '1:34:35', + date: '2026-02-03', + ), + Livestream( + videoId: 'bAljWKk3ZfM', + number: 15, + topic: 'Building a pipeline using Flutter + Firebase + Gemini 3 Pro', + length: '1:27:46', + date: '2025-12-01', + ), + Livestream( + videoId: 'c3OLJcSGrFw', + number: 14, + topic: 'First impressions of Gemini CLI, AI Studio, Antigravity, Stitch & Jules', + length: '1:24:06', + date: '2025-11-24', + ), + Livestream( + videoId: 'gJW6FR_X5P0', + number: 13, + topic: 'Prompt, Code, Think: Gemini CLI Extensions × Flutter with Jhin Lee', + length: '1:10:59', + date: '2025-11-03', + ), + Livestream( + videoId: '6EWjQUhW9eE', + number: 12, + topic: 'Choose Your Own Adventure App Part 2 with Randal Schwartz', + length: '1:28:16', + date: '2025-10-20', + ), + Livestream( + videoId: 'YlfXYIISlIY', + number: 11, + topic: 'Choose Your Own Adventure App with Randal Schwartz', + length: '1:59:01', + date: '2025-10-06', + ), + Livestream( + videoId: 'K8R_0P_jI4I', + number: 10, + topic: 'Prompt, Code, Think: Flutter x Firebase AI Logic Agentic Apps with Khanh & Ander', + length: '1:55:31', + date: '2025-08-11', + ), + Livestream( + videoId: 'xt5Ol3ZrdF4', + number: 9, + topic: 'Prompt, Code, Think: Agentic AI in Dart with dartantic_ai', + length: '1:49:30', + date: '2025-07-28', + ), + Livestream( + videoId: 'WZVyDMZ97eY', + number: 8, + topic: 'Tool Talk: ClaudeCode, Prompt enhancer, & RooCode Enhanced Checklist Management', + length: '1:16:32', + date: '2025-07-21', + ), + Livestream( + videoId: 'Mls_V7wjCCI', + number: 7, + topic: 'Building a Real-Time Flame Game MCP Server with AI Assistants (with @salihgueler)', + length: '1:44:59', + date: '2025-07-14', + ), + Livestream( + videoId: 'QVCpoWWLYZo', + number: 6, + topic: 'flutter_gemma Package & Quick Look at Gemini CLI', + length: '2:07:15', + date: '2025-07-07', + ), + Livestream( + videoId: 'km0ImsSNXSo', + number: 5, + topic: 'Firebase Studio, mcp_dart Package & Roo Code Updates', + length: '1:47:13', + date: '2025-06-23', + ), + Livestream( + videoId: 'KPugx13h-bw', + number: 4, + topic: 'Agentic Apps Hands-On and RooCode', + length: '1:39:22', + date: '2025-06-09', + ), + Livestream( + videoId: 'QRDBqSXKu7E', + number: 3, + topic: 'I/O AI Integration Updates', + length: '1:37:28', + date: '2025-06-02', + ), + Livestream( + videoId: 'vJv9Fz61ieY', + number: 2, + topic: 'Prompting, Tools, Rules, and Dev Insights', + length: '2:21:22', + date: '2025-05-12', + ), + Livestream( + videoId: 'Gub1DY8ScwU', + number: 1, + topic: 'Prompt, Code, Think: Welcome to AI in Flutter', + length: '2:27:40', + date: '2025-05-05', + ), +]; diff --git a/lib/src/pages/livestreams_page.dart b/lib/src/pages/livestreams_page.dart new file mode 100644 index 0000000..abd680e --- /dev/null +++ b/lib/src/pages/livestreams_page.dart @@ -0,0 +1,160 @@ +import 'package:fc_ai_circle/src/app/external_links.dart'; +import 'package:fc_ai_circle/src/components/page_hero.dart'; +import 'package:fc_ai_circle/src/data/livestream.dart'; +import 'package:fc_ai_circle/src/data/livestreams.g.dart'; +import 'package:fc_ai_circle/src/layouts/page_layout.dart'; +import 'package:jaspr/browser.dart'; +import 'package:jaspr_router/jaspr_router.dart'; + +/// Every FCAIC episode, newest first, grouped by year and searchable by number or topic. +/// +/// The episode list is generated: run `dart run tool/fetch_livestreams.dart` after each stream. +class LivestreamsPage extends StatefulComponent { + const LivestreamsPage({super.key}); + + static const path = '/livestreams'; + + static Iterable route() sync* { + yield Route( + path: path, + title: 'Livestreams - Flutter Community AI Circle', + builder: (BuildContext context, RouteState state) => const LivestreamsPage(), + ); + } + + @override + State createState() => _LivestreamsPageState(); +} + +class _LivestreamsPageState extends State { + String _search = ''; + + String get _query => _search.trim().toLowerCase(); + + @override + Iterable build(BuildContext context) sync* { + final latest = livestreams.first; + final archive = livestreams.skip(1).where((episode) => episode.matches(_query)).toList(); + final years = {for (final episode in archive) episode.year}.toList() + ..sort((first, second) => second.compareTo(first)); + + yield PageLayout( + children: [ + PageHero( + title: 'Livestreams', + description: 'The Flutter Community AI Circle goes live on the Flutter Community ' + 'YouTube channel: we build agentic apps, try new AI tools on real Flutter code, ' + 'and bring on guests from the community.', + callout: _HeroCallout(), + ), + section(classes: 'livestreams container', [ + _LivestreamCard(episode: latest, featured: true), + div(classes: 'livestreams-tools', [ + input( + [], + type: InputType.search, + classes: 'livestreams-search', + value: _search, + attributes: { + 'placeholder': 'Search episodes: Gemini, MCP, Genkit…', + 'aria-label': 'Search episodes', + }, + onInput: (value) => setState(() => _search = '$value'), + ), + span( + classes: 'livestreams-count', + attributes: {'aria-live': 'polite'}, + [if (_query.isNotEmpty) text('${archive.length} of ${livestreams.length - 1}')], + ), + ]), + for (final year in years) + div(classes: 'livestreams-year', [ + h2([text(year)]), + div(classes: 'livestreams-grid', [ + for (final episode in archive.where((episode) => episode.year == year)) + _LivestreamCard(episode: episode), + ]), + ]), + if (archive.isEmpty) + p(classes: 'livestreams-empty', [text('No episode matches that search.')]), + ]), + ], + ); + } +} + +class _HeroCallout extends StatelessComponent { + const _HeroCallout(); + + @override + Iterable build(BuildContext context) sync* { + const secondsPerHour = 3600; + final totalHours = + (livestreams.fold(0, (total, episode) => total + episode.lengthInSeconds) / secondsPerHour) + .round(); + + yield div(classes: 'livestreams-callout', [ + ul(classes: 'livestreams-stats', [ + li([text('${livestreams.length} episodes')]), + li([text('about $totalHours hours live')]), + li([text('since ${livestreams.last.monthYear}')]), + ]), + div(classes: 'buttons-container', [ + a( + classes: 'cta_button', + href: ExternalLink.youTubePlaylist.url, + target: Target.blank, + attributes: {'rel': 'noopener noreferrer'}, + [text('Watch the playlist')], + ), + a( + classes: 'secondary-button', + href: ExternalLink.sessionizeCallForSpeakers.url, + target: Target.blank, + attributes: {'rel': 'noopener noreferrer'}, + [text('Pitch a topic')], + ), + ]), + ]); + } +} + +class _LivestreamCard extends StatelessComponent { + const _LivestreamCard({required this.episode, this.featured = false}); + + final Livestream episode; + final bool featured; + + @override + Iterable build(BuildContext context) sync* { + yield a( + classes: featured ? 'livestream-card featured' : 'livestream-card', + href: episode.watchUrl, + target: Target.blank, + attributes: {'rel': 'noopener noreferrer'}, + [ + span(classes: 'livestream-thumb', [ + img( + src: episode.thumbnailUrl(large: featured), + alt: '', + loading: featured ? MediaLoading.eager : MediaLoading.lazy, + referrerPolicy: ReferrerPolicy.noReferrer, + ), + if (episode.length.isNotEmpty) span(classes: 'livestream-length', [text(episode.length)]), + ]), + span(classes: 'livestream-meta', [ + span(classes: 'livestream-number', [ + text('${featured ? 'Latest episode · ' : ''}#${episode.number}'), + ]), + span(classes: 'livestream-topic', [text(episode.topic)]), + DomComponent( + tag: 'time', + classes: 'livestream-date', + attributes: {'datetime': episode.date}, + children: [text(episode.prettyDate)], + ), + ]), + ], + ); + } +} diff --git a/tool/fetch_livestreams.dart b/tool/fetch_livestreams.dart new file mode 100644 index 0000000..7e22f37 --- /dev/null +++ b/tool/fetch_livestreams.dart @@ -0,0 +1,200 @@ +/// Regenerates lib/src/data/livestreams.g.dart from the FCAIC YouTube playlist. +/// +/// Run from the repo root after each stream: `dart run tool/fetch_livestreams.dart` +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:fc_ai_circle/src/data/livestream.dart'; + +const outputPath = 'lib/src/data/livestreams.g.dart'; +const fetchWorkers = 4; +const fetchAttempts = 4; +const headers = { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 ' + 'Chrome/126 Safari/537.36', + 'Accept-Language': 'en', + 'Cookie': 'CONSENT=YES+1; SOCS=CAI', +}; + +final client = HttpClient(); + +Future main() async { + try { + final playlistPage = await fetch( + 'https://www.youtube.com/playlist?list=${Livestream.playlistId}&hl=en', + ); + final videos = <({String id, String title, String length})>[]; + collectVideos(initialData(playlistPage), videos); + if (videos.isEmpty) { + throw StateError('No videos found in the playlist page.'); + } + + final dates = []; + for (var start = 0; start < videos.length; start += fetchWorkers) { + final batch = videos.skip(start).take(fetchWorkers); + dates.addAll(await Future.wait(batch.map((video) => streamDate(video.id)))); + } + + final episodes = [ + for (final (index, video) in videos.indexed) episode(video, dates[index]), + ]..sort((first, second) => second.number.compareTo(first.number)); + + await File(outputPath).writeAsString(render(episodes)); + await Process.run('dart', ['format', outputPath]); + stdout.writeln('${episodes.length} livestreams written to $outputPath'); + } finally { + client.close(); + } +} + +Future fetch(String url) async { + final request = await client.getUrl(Uri.parse(url)); + headers.forEach(request.headers.set); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url)); + } + return response.transform(utf8.decoder).join(); +} + +/// Extracts the `ytInitialData` JSON object embedded in a YouTube page. +Object? initialData(String page) { + final start = page.indexOf('{', page.indexOf('ytInitialData')); + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < page.length; index++) { + final character = page[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character == r'\') { + escaped = true; + } else if (character == '"') { + inString = false; + } + } else if (character == '"') { + inString = true; + } else if (character == '{') { + depth++; + } else if (character == '}' && --depth == 0) { + return jsonDecode(page.substring(start, index + 1)); + } + } + throw const FormatException('ytInitialData is not a complete JSON object.'); +} + +void collectVideos(Object? node, List<({String id, String title, String length})> videos) { + if (node is Map) { + final lockup = node['lockupViewModel']; + if (lockup is Map) { + final title = lockup['metadata']?['lockupMetadataViewModel']?['title']?['content']; + final badges = collectTexts(lockup['contentImage'], []) + .where((text) => RegExp(r'^[\d:]+$').hasMatch(text)); + videos.add(( + id: lockup['contentId'] as String, + title: title is String ? title : '', + length: badges.isEmpty ? '' : badges.first, + )); + return; + } + for (final value in node.values) { + collectVideos(value, videos); + } + } else if (node is List) { + for (final value in node) { + collectVideos(value, videos); + } + } +} + +List collectTexts(Object? node, List found) { + if (node is Map) { + for (final key in ['content', 'text']) { + if (node[key] case final String text) found.add(text); + } + for (final value in node.values) { + collectTexts(value, found); + } + } else if (node is List) { + for (final value in node) { + collectTexts(value, found); + } + } + return found; +} + +/// YouTube now and then serves a watch page without the player data, so a few attempts are normal. +Future streamDate(String videoId) async { + final streamStart = RegExp(r'"startTimestamp":"([^"]+)"'); + final published = RegExp(r'"publishDate":"([^"]+)"'); + for (var attempt = 0; attempt < fetchAttempts; attempt++) { + final page = await fetch('https://www.youtube.com/watch?v=$videoId&hl=en'); + final match = streamStart.firstMatch(page) ?? published.firstMatch(page); + if (match != null) { + return berlinDate(DateTime.parse(match.group(1)!).toUtc()); + } + } + throw StateError('No stream date found for $videoId after $fetchAttempts attempts.'); +} + +/// Europe/Berlin is UTC+2 from the last Sunday of March to the last Sunday of October +/// (switching at 01:00 UTC), and UTC+1 otherwise. +String berlinDate(DateTime utc) { + DateTime lastSundayOneAm(int month) { + final lastDay = DateTime.utc(utc.year, month + 1, 0, 1); + return lastDay.subtract(Duration(days: lastDay.weekday % DateTime.daysPerWeek)); + } + + final summerTime = !utc.isBefore(lastSundayOneAm(DateTime.march)) && + utc.isBefore(lastSundayOneAm(DateTime.october)); + final local = utc.add(Duration(hours: summerTime ? 2 : 1)); + return local.toIso8601String().substring(0, 10); +} + +Livestream episode(({String id, String title, String length}) video, String date) { + final match = RegExp(r'^\s*(?:FCAIC\s*)?#(\d+)\s*[-–:]?\s*(.*)$').firstMatch(video.title); + if (match == null) { + throw FormatException('Title has no episode number: ${video.title}'); + } + final topic = match.group(2)!.replaceAll(RegExp(r'\s+'), ' ').replaceAll(' – ', ': ').trim(); + return Livestream( + videoId: video.id, + number: int.parse(match.group(1)!), + topic: topic, + length: video.length, + date: date, + ); +} + +String render(List episodes) { + final buffer = StringBuffer() + ..writeln('// Generated by tool/fetch_livestreams.dart. Do not edit by hand.') + ..writeln() + ..writeln("import 'package:fc_ai_circle/src/data/livestream.dart';") + ..writeln() + ..writeln('const livestreams = ['); + for (final episode in episodes) { + buffer + ..writeln(' Livestream(') + ..writeln(' videoId: ${literal(episode.videoId)},') + ..writeln(' number: ${episode.number},') + ..writeln(' topic: ${literal(episode.topic)},') + ..writeln(' length: ${literal(episode.length)},') + ..writeln(' date: ${literal(episode.date)},') + ..writeln(' ),'); + } + buffer.writeln('];'); + return buffer.toString(); +} + +String literal(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$') + .replaceAll('\n', r'\n'); + return "'$escaped'"; +} diff --git a/web/css/styles.css b/web/css/styles.css index b7bef86..0295131 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -1761,3 +1761,172 @@ button:focus-visible { background: radial-gradient(circle at 50% 30%, rgba(171, 87, 255, 0.03) 0%, transparent 70%); animation: pulse-bg 10s infinite alternate ease-in-out; } + +/* Livestreams page */ +.livestreams-callout { + display: grid; + gap: var(--spacing-md); + justify-items: center; +} + +.livestreams-stats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--spacing-sm); + margin: 0; + padding: 0; + list-style: none; +} + +.livestreams-stats li { + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 4px 12px; + font-size: 14px; + color: var(--text-color); +} + +.livestreams { + padding-bottom: var(--spacing-xl); +} + +.livestream-card { + display: grid; + gap: 10px; + padding: 10px; + border-radius: 16px; + color: var(--text-color); + text-decoration: none; + transition: background var(--transition-speed); +} + +.livestream-card:hover, +.livestream-card:focus-visible { + background: var(--card-bg); + color: var(--text-color); +} + +.livestream-card:focus-visible, +.livestreams-search:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 3px; +} + +.livestream-thumb { + position: relative; + display: block; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--border-color); + background: var(--primary-color); +} + +.livestream-thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.5s; +} + +.livestream-card:hover .livestream-thumb img { + transform: scale(1.03); +} + +.livestream-length { + position: absolute; + right: 8px; + bottom: 8px; + padding: 2px 6px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.78); + color: #ffffff; + font-size: 12px; + font-weight: 600; +} + +.livestream-meta { + display: grid; + gap: 2px; +} + +.livestream-number { + font-size: 12px; + font-weight: 600; + color: var(--accent-color); +} + +.livestream-topic { + font-weight: 500; +} + +.livestream-date { + font-size: 13px; + color: var(--secondary-text); +} + +.livestream-card.featured { + margin-bottom: var(--spacing-lg); + padding: 14px; + border: 1px solid var(--border-color); + background: var(--card-bg); +} + +.livestream-card.featured .livestream-topic { + font-size: 20px; + line-height: 1.3; +} + +@media (min-width: 769px) { + .livestream-card.featured { + grid-template-columns: 1.4fr 1fr; + align-items: center; + gap: var(--spacing-md); + } +} + +.livestreams-tools { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.livestreams-search { + width: min(360px, 100%); + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--primary-color); + color: var(--text-color); + font: inherit; +} + +.livestreams-count, +.livestreams-empty { + color: var(--secondary-text); +} + +.livestreams-year h2 { + margin: 28px 0 8px; + font-size: 13px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-color); +} + +.livestreams-grid { + display: grid; + gap: var(--spacing-sm); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); +} + +@media (prefers-reduced-motion: reduce) { + .livestream-card, + .livestream-thumb img { + transition: none; + } +}