diff --git a/.clinerules b/.clinerules index 53ea092..de5fb24 100644 --- a/.clinerules +++ b/.clinerules @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/.continuerules b/.continuerules index 53ea092..de5fb24 100644 --- a/.continuerules +++ b/.continuerules @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/.cursorrules b/.cursorrules index 53ea092..de5fb24 100644 --- a/.cursorrules +++ b/.cursorrules @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 53ea092..de5fb24 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5236405..2f9961a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,35 @@ jobs: - name: Build iOS (release, no codesign) run: flutter build ios --release --no-codesign + build-web: + name: Build Web + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + .dart_tool + key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} + restore-keys: pub-${{ runner.os }}- + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: Build web (release) + run: flutter build web --release --no-tree-shake-icons + dependency-audit: name: Dependency Audit runs-on: ubuntu-latest diff --git a/.windsurfrules b/.windsurfrules index 53ea092..de5fb24 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/AGENTS.md b/AGENTS.md index 53ea092..de5fb24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 53ea092..de5fb24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,16 +18,18 @@ If you need to update agent instructions, edit **this file** (`AGENTS.md`) and r ## Project Overview -NullFeed is a self-hosted YouTube media center. This is the Flutter client -targeting iOS only (Apple TV is covered by the separate native tvOS app, -`nullfeed-tvos`). +NullFeed is a self-hosted YouTube media center. This is the Flutter client, +primarily for **iOS**, plus a **Flutter Web build (beta)**. (Apple TV is covered +by the separate native tvOS app, `nullfeed-tvos`.) Web has no on-device storage, +so the offline-save feature and its "Offline" tab are hidden on web (`kIsWeb` +guards); keep new platform-specific code behind `kIsWeb` / conditional imports. **Stack:** Flutter 3.41+, Dart 3.11+, Riverpod 3.x, Freezed 3.x, GoRouter 17.x, Hive, Dio, video_player. ## CI Pipeline -This repo has 6 CI checks that run on every PR: +This repo has 7 CI checks that run on every PR: | Check | What it does | |-------|-------------| @@ -35,6 +37,7 @@ This repo has 6 CI checks that run on every PR: | **Analyze** | `flutter analyze --fatal-infos --fatal-warnings` | | **Test** | `flutter test` | | **Build iOS** | `flutter build ios --release --no-codesign` (runs on `macos-latest`) | +| **Build Web** | `flutter build web --release` (runs on `ubuntu-latest`) | | **Dependency Audit** | Warns on major version drift (non-blocking) | | **Agent Rules Sync** | Verifies all agent instruction files match `AGENTS.md` | diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 690d62a..6f53ebf 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -121,29 +122,34 @@ class _ScaffoldWithNav extends ConsumerWidget { final Widget child; const _ScaffoldWithNav({required this.child}); + static const List<_NavDest> _allDestinations = [ + _NavDest('/home', Icons.home_outlined, Icons.home, 'Home'), + _NavDest( + '/library', + Icons.video_library_outlined, + Icons.video_library, + 'Library', + ), + _NavDest('/discover', Icons.explore_outlined, Icons.explore, 'Discover'), + _NavDest( + '/downloads', + Icons.offline_pin_outlined, + Icons.offline_pin, + 'Offline', + ), + _NavDest('/settings', Icons.settings_outlined, Icons.settings, 'Settings'), + ]; + + /// The Offline tab is on-device storage, which the web build doesn't have, so + /// it's dropped on web. + static List<_NavDest> get _destinations => kIsWeb + ? _allDestinations.where((d) => d.route != '/downloads').toList() + : _allDestinations; + static int _calculateSelectedIndex(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; - if (location.startsWith('/home')) return 0; - if (location.startsWith('/library')) return 1; - if (location.startsWith('/discover')) return 2; - if (location.startsWith('/downloads')) return 3; - if (location.startsWith('/settings')) return 4; - return 0; - } - - void _onTap(BuildContext context, int index) { - switch (index) { - case 0: - context.go('/home'); - case 1: - context.go('/library'); - case 2: - context.go('/discover'); - case 3: - context.go('/downloads'); - case 4: - context.go('/settings'); - } + final index = _destinations.indexWhere((d) => location.startsWith(d.route)); + return index < 0 ? 0 : index; } @override @@ -152,42 +158,32 @@ class _ScaffoldWithNav extends ConsumerWidget { // events keep flowing and a Settings-triggered invalidate reconnects // immediately instead of waiting for the Home tab to be visited. ref.watch(webSocketConnectionProvider); + final destinations = _destinations; final selectedIndex = _calculateSelectedIndex(context); return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( currentIndex: selectedIndex, - onTap: (index) => _onTap(context, index), + onTap: (index) => context.go(destinations[index].route), type: BottomNavigationBarType.fixed, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - activeIcon: Icon(Icons.home), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.video_library_outlined), - activeIcon: Icon(Icons.video_library), - label: 'Library', - ), - BottomNavigationBarItem( - icon: Icon(Icons.explore_outlined), - activeIcon: Icon(Icons.explore), - label: 'Discover', - ), - BottomNavigationBarItem( - icon: Icon(Icons.offline_pin_outlined), - activeIcon: Icon(Icons.offline_pin), - label: 'Offline', - ), - BottomNavigationBarItem( - icon: Icon(Icons.settings_outlined), - activeIcon: Icon(Icons.settings), - label: 'Settings', - ), + items: [ + for (final d in destinations) + BottomNavigationBarItem( + icon: Icon(d.icon), + activeIcon: Icon(d.activeIcon), + label: d.label, + ), ], ), ); } } + +class _NavDest { + final String route; + final IconData icon; + final IconData activeIcon; + final String label; + const _NavDest(this.route, this.icon, this.activeIcon, this.label); +} diff --git a/lib/providers/websocket_provider.dart b/lib/providers/websocket_provider.dart index 054c53e..1718070 100644 --- a/lib/providers/websocket_provider.dart +++ b/lib/providers/websocket_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/websocket_service.dart'; import '../services/storage_service.dart'; @@ -57,8 +58,9 @@ final webSocketConnectionProvider = Provider((ref) { ref.invalidate(newEpisodesProvider); ref.invalidate(recentlyAddedProvider); ref.invalidate(homeFeedProvider); - // Auto-offline: download to device if enabled for this channel - if (videoId != null && channelId != null) { + // Auto-offline: download to device if enabled for this channel. + // Web has no on-device storage, so it never auto-saves. + if (!kIsWeb && videoId != null && channelId != null) { final storageService = ref.read(storageServiceProvider); if (storageService.isAutoOfflineEnabled(channelId)) { final offlineService = ref.read(offlineServiceProvider); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4b05a3e..a7bd4cd 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -98,7 +99,8 @@ class _VideoPlayerScreenState extends ConsumerState { Future _initPlayer() async { try { // Path 0: Offline — play the local file without requiring network. - if (_offline.isAvailableOffline(widget.videoId)) { + // Skipped on web, which has no on-device files (and no dart:io File). + if (!kIsWeb && _offline.isAvailableOffline(widget.videoId)) { final localPath = _offline.getLocalPath(widget.videoId); if (localPath != null) { await _startOfflinePlayback(localPath); diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index 66e0f7e..1eac41f 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:path_provider/path_provider.dart'; @@ -64,6 +65,9 @@ class OfflineService { String? title, String? youtubeVideoId, }) async { + // Web has no on-device filesystem; offline save is unsupported there. The + // UI hides it, but guard defensively so a stray call can't throw. + if (kIsWeb) return; // In-flight guard: a second tap must not start a concurrent download // writing to the same file. Registered before the first await. if (_cancelTokens.containsKey(videoId)) return; diff --git a/lib/widgets/video_list_tile.dart b/lib/widgets/video_list_tile.dart index 427740c..7477d7a 100644 --- a/lib/widgets/video_list_tile.dart +++ b/lib/widgets/video_list_tile.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -58,7 +59,9 @@ class _VideoListTileState extends ConsumerState { String? _downloadStateLabel(String? offlineStatus) { // Server-side caching is invisible; only the explicit on-device "save - // offline" state is surfaced (and only for cached/complete videos). + // offline" state is surfaced (and only for cached/complete videos). Web has + // no on-device storage, so there's no offline state at all. + if (kIsWeb) return null; if (widget.video.status != VideoStatus.complete) return null; if (offlineStatus == 'complete') return 'saved offline'; if (offlineStatus == 'downloading') return 'saving offline'; @@ -66,6 +69,9 @@ class _VideoListTileState extends ConsumerState { } Widget _buildTrailingWidget() { + // No on-device storage on web, so there's no per-video "save offline" + // action at all. + if (kIsWeb) return const SizedBox.shrink(); // The only per-video action is "save offline", available once a video is // cached (COMPLETE). Un-cached episodes show nothing — caching happens // quietly in the background.