* migrate to dio

* ignore folders

* simplify twitch api headers handling

* clean up main

* simplify logs

* optimize profile picture fetching
fixes refetches on tab switch

* update claude md

* refactor irc.dart

* update copilot instructions with essential commands and project structure details

* implement message sending state management and UI updates

* refactor notification handling and add copy-paste functionality in chat

* refactor AlertMessage to support vertical layout and optional padding

* refactor error messages for consistency and clarity across various components

* refactor chat layout to use draggable divider and improve chat width settings

* refactor Uptime widget to use SharedTimerService for synchronized updates

* refactor search results components to handle null future states gracefully

* refactor loading indicators to use skeleton loaders for improved user experience

* refactor UI components for improved layout and responsiveness, enhance skeleton loaders, and streamline padding adjustments

* refactor: replace manual blur effects with BlurredContainer for consistency and reusability

* refactor: enhance JavaScript execution and error handling in WebView components

* refactor: replace app bar with blurred container for improved UI consistency across screens

* refactor: streamline video chat layout and enhance PiP drag gesture functionality

* revert some app bar changes

* refactor: implement Picture-in-Picture drag functionality in VideoChat

* refactor: pass currentChannelId to IRCMessage and ChatMessage for improved context

* fix large thubmnail width

* chore: update Gradle and Kotlin plugin versions for improved compatibility

* changes from v4.10.0 branch

* feat: integrate AnimatedScrollBorder for improved UI responsiveness across various screens

* refactor: update SectionHeader styling and improve theme management

* feat: add pinned section to StreamsList and enhance skeleton loader padding

* feat: add repository guidelines for project structure, coding style, testing, and security

* feat: enhance StreamCard UI with BlurredContainer and adjust border width in theme

* feat: implement unfocusInput and safeRequestFocus methods to manage text field focus behavior

* feat: enhance Picture-in-Picture drag interactions with haptic feedback and smooth animations

* refactor: improve code formatting and readability across multiple files

* feat: implement picture-in-picture mode toggle and state management for video playback

* feat: reset PiP mode state when pausing video playback

* feat: add custom app bar with profile picture and stream info for video chat

* feat: convert multiple settings screens to use StatefulWidget and add animated scroll borders

* feat: update blur configuration for adaptive alpha based on theme

* fix: adjust default font size in SectionHeader widget

* feat: enhance PiP mode with drag overlay instruction and UI improvements

* fix: improve badge rendering with consistent margin and border for current channel

* refactor: replace BackdropFilter with BlurredContainer for improved clarity and maintainability

* fix: reduce background transparency in Uptime widget for better visibility

* feat: enhance photo view dialog with full resolution toggle and blurred background

* fix: reset chat delay to 0 when auto sync is enabled and update settings title for clarity

* fix: adjust border width in theme to improve consistency

* feat: add forceDarkMode option to BlurredContainer for improved theme adaptability

* feat: implement offline followed channels feature with UI integration and settings option

* fix: increase font size and weight for offline status text for better readability

* feat: remove offline pinned channels feature and update related UI components

* feat: optimize offline channels loading behavior based on expansion state

* fix: improve clarity and consistency in repository guidelines formatting

* feat: update full resolution URL handling and button visibility logic

* feat: replace app bar with blurred version in chat-only mode and add animated scroll border

* feat: refactor video chat layout for improved chat and video display in landscape mode

* feat: remove bottom bar visibility setting and adjust chat layout accordingly

* fix: simplify condition for displaying offline channels section in following tab

* feat: enhance reply thread display with indentation and reply icon

* feat: implement showModalBottomSheetWithProperFocus for improved focus handling

* feat: add gradient direction support to BlurredContainer for enhanced visual effects

* feat: enhance NavigationBar and Theme settings for improved visual consistency

* feat: add gradient direction support to BlurredContainer in VideoChat for enhanced visual effects

* chore: update .gitignore to include .cursor file for better environment management

* fix: update PiP drag gesture conditions to include video paused state for better user experience

* refactor: replace ScrollController with SettingsPageLayout in settings screens for improved layout management

* refactor: centralize orientation handling by introducing OrientationUtils for improved readability and maintainability

* refactor: replace OrientationUtils with ContextExtensions for streamlined context-based access to theme, layout, and API dependencies

* refactor: improve video chat UI by adjusting title layout and adding stream info tooltips for better user experience

* refactor: adjust titleSpacing in theme and navigation components for improved layout consistency

* refactor: update stream info timer handling and overlay management for improved UI responsiveness

* refactor: enhance stream info timer logic and overlay visibility management for chat-only mode

* refactor: enhance input decoration theme in FrostyThemes for improved visual consistency and responsiveness

* refactor: adjust inputDecorationTheme padding in FrostyThemes for improved visual consistency

* refactor: enhance video overlay management by including video preview overlay in hideElements for improved UI consistency

* refactor: improve chat bottom bar padding and video chat layout for enhanced UI consistency

* refactor: adjust chat padding to account for device bottom insets for improved layout consistency

* refactor: update text styles in FrostyThemes for improved visual consistency across UI components

* refactor: update iOS minimum deployment target to 13.0 for improved compatibility

* refactor: adjust chat bottom padding based on emote menu visibility for improved layout consistency

* refactor: adjust padding values in StreamsList for improved layout consistency

* refactor: replace Padding with AnimatedPadding in Chat for smoother layout transitions
refactor: replace Container with AnimatedContainer in BlurredContainer for smoother animations

* refactor: remove unnecessary radius property from ProfilePicture in OfflineChannelCard for cleaner code

* refactor: enhance chat bottom bar suffix icon to display message delay with tooltip for improved user experience

* refactor: update hint text and tooltips in chat bottom bar and search for improved clarity

* refactor: simplify notification display logic and enhance chat input behavior for improved user experience

* refactor: enhance layout consistency by adjusting spacing in various components across the channel and home screens

* refactor: introduce LiveIndicator widget and adjust spacing in channel and video components for improved layout consistency

* refactor: remove monospace font family from chat bottom bar for improved text rendering

* refactor: reduce notification animation duration and change dismiss direction for improved user experience

* refactor: replace VideoBar with StreamInfoBar in video components for improved layout and consistency

* refactor: adjust font sizes and spacing in StreamInfoBar for improved layout consistency

* refactor: standardize font sizes and update gradient stops in video components for improved visual consistency

* refactor: update tooltip padding and background color in theme for improved visual consistency

* refactor: enhance tooltip behavior and standardize font sizes in StreamInfoBar and related components for improved visual consistency

* refactor: add showThumbnail option to LargeStreamCardSkeletonLoader for improved layout flexibility

* refactor: add StreamInfoBarSkeletonLoader for improved layout consistency in stream components

* refactor: add shadow effects to icons and text in StreamInfoBar and VideoOverlay for improved visual depth

* refactor: update defaultOverlayOpacity to 0.0 for improved overlay behavior

* fix: correct stream info text color on overlay

* refactor: implement a new color picker dialog with random color generation for enhanced user experience

* refactor: adjust gradient colors and stops in VideoOverlay for improved visual consistency

* refactor: refine gradient colors and stops in VideoOverlay for enhanced visual clarity

* refactor: add isCompact parameter to StreamInfoBar for responsive text sizing

* fix: update tooltip background color to use surface color for better contrast

* feat: add shared chat mode support in chat store and update StreamInfoBar for visual indication

* refactor: disable debug print for IRC messages to reduce log clutter

* feat: implement chat color selection feature with user color management in chat settings

* refactor: replace color adjustment logic with new contrast-aware utility for chat names

* refactor: simplify chat details UI by replacing list tiles with action cards and removing unused clear dialog

* refactor: enhance chat details UI by improving time formatting and adding label styling to action cards

* refactor: update chat color terminology to 'Username color' for clarity in UI

* refactor: enhance chat details UI by introducing active chat modes display and improving layout structure

* refactor: improve chat details layout by replacing ListTile with Padding and adding a Divider for better visual separation

* refactor: remove chat notifications positioning setting and adjust notification alignment in video chat

* refactor: streamline StreamInfoBar component by consolidating offline handling and improving layout for better readability

* refactor: enhance StreamInfoBar by adding tooltip for streamer name and improving text overflow handling

* refactor: adjust tooltip display duration in StreamInfoBar and update theme configuration

* refactor: adjust margin and padding in StreamCard for improved layout consistency

* chore: update dependencies in pubspec.yaml and pubspec.lock for Firebase and other packages

* template

* chore: update iOS platform version to 15.0 and Firebase dependencies to 12.2.0

* refactor: simplify StreamInfoBar layout by reducing nested widgets and improving text color handling

* format

* feat: enhance Channel model and StreamInfoBar to support offline channel details

* feat: add offline indicator and improve StreamInfoBar layout for better offline channel display

* fix: adjust icon color opacity in StreamInfoBar for improved visibility

* feat: enhance dismissal animation in FrostyPhotoViewDialog to track swipe direction

* docs: update copilot instructions for clarity and additional error handling details

* refactor: optimize latency tooltip layout and improve offline indicator styling in VideoOverlay

* feat: implement shared chat asset fetching and participant management in ChatAssetsStore

* refactor: replace Observer with Builder for improved widget rebuilding in VideoOverlay

* refactor: update StreamInfoBar border styling and adjust layout in VideoOverlay for improved visual consistency

* feat: add showTextShadows property to StreamInfoBar for customizable text shadow display

* refactor: adjust layout in VideoOverlay by adding padding to improve spacing and visual consistency

* refactor: enhance VideoOverlay gradients and layout for improved visual hierarchy and user experience

* refactor: reduce vertical padding in VideoOverlay for improved layout consistency

* refactor: remove overlay opacity settings from VideoSettings and SettingsStore for simplification

* refactor: improve text overlay transition in VideoChat for better user experience

* Revert "refactor: improve text overlay transition in VideoChat for better user experience"

This reverts commit fd16ff5ec3.

* refactor: wrap videoOverlay in ColoredBox for improved layout handling

* refactor: optimize animation duration and transition curves in VideoChat for smoother interactions

* refactor: dispose searchScrollController in HomeStoreBase to prevent memory leaks

* refactor: simplify gradient definitions in VideoOverlay for improved readability and maintainability

* refactor: remove 'Offline' text overlay from VideoOverlay for cleaner UI

* refactor: add refresh functionality for assets in ChatDetails with loading indicators

* refactor: update visibility conditions for uptime, viewer count, and category display in StreamInfoBar

* refactor: replace AlertDialog with FrostyDialog for consistent dialog styling across the application

* refactor: enhance layout handling in FrostyPageView to prevent overflow during height transitions

* refactor: replace ColorPickerDialog with AccentColorPickerDialog for improved color selection experience

* refactor: replace theme and accent color settings with dedicated components for improved modularity and readability

* refactor: replace ElevatedButton with TextButton for consistent button styling across dialogs

* refactor: integrate MobX Observer for reactive state management in settings widgets

* refactor: update theme and dialog components for improved styling and consistency

* refactor: enhance dialog styling with rounded corners and border in theme configuration

* refactor: improve dialog messages for unauthorized access handling

* refactor: update bodyLarge text style in theme configuration to include font weight

* refactor: standardize Divider usage and adjust theme styles for improved consistency

* refactor: adjust Divider properties and theme styles for enhanced visual consistency

* chore: remove GEMINI.md file as it is no longer needed for project documentation

* refactor: enhance token validation and reconnect logic in AuthBase for improved error handling

* format

* refactor: integrate landscape cutout settings into VideoChat layout for improved responsiveness

* refactor: implement app lifecycle handling in VideoChat and enhance stream info update logic in VideoStore

* refactor: update landscape cutout conditionals in VideoChat layout for improved responsiveness

* update AGENTS.md

* add scrollbar to search

* fix scrolling exception

* update CLAUDE.md

* 5 auth reconnect attemps then log out

* increase reply max lines to 2

* simplify and improve reply formatting

* add prime icon

* better draggable divider

* support draggable divider in fullscreen overlay chat

* add text shadow in fullscreen chat overlay mode

* format

* add landscape checks

* video store optimization attempts
* Dispose WebViewController and remove JavaScript channels properly
  * Track and cleanup all MutationObservers to prevent accumulation
  * Throttle latency updates to once per 5 seconds (reduces CPU load)
  * Add timeout protection and null checks for robustness
  * Prevent duplicate event listeners on page reloads

* double tap divider to reset

* Revert "video store optimization attempts"

This reverts commit b1df7b0de4.

* optimize video store attempt 2

* Revert "double tap divider to reset"

This reverts commit aeb26a4335.

* optimize latency listener to open the menu and check every 20 secconds

* more optimizations

* hard refresh

* abbreviate latency

* fix lint

* improve hide default overlay

* fix chat no longer reconnecting with chat delay

* refine chat alerts

* fix historical message missing badges and emotes on initial connection

* parallelize

* revert

* add reconnect button inline action

* refactor: replace Scrollbar with FrostyScrollbar across chat and settings screens for consistent styling

* tweak chat details menu

* add scrollbar to release notes, renames for consistency

* fix duplicate message case

* fix chat delay alerts in auto sync mode

* add regex for numeric strings and enhance display name formatting logic

* exterminate borders

* refactor: simplify active chat modes display in chat details screen

* feat: enhance chat input button with delay notification and disable state

* fix: wrap chat in SafeArea to prevent UI overflow issues

* refactor: remove divider line from video chat and update draggable divider visibility logic

* persist overlay in pip mode

* fix: update chat delay handling and notifications in chat bottom bar

* fix: update chat overlay visibility logic to respect fullscreen settings

* tap to show deleted message

* fix: prevent accidental chat scrolling when accessing system UI in landscape mode

* show latency in portrait

* fix: enhance chat delay notifications with dynamic delay time

* docs: update CLAUDE.md for improved environment variable instructions and architecture overview

* fix: improve chat delay countdown management and message handling

* add preliminary release notes

* remove agents.md

* update lockfile

* add initial multi chat feature

* add option to persist multi chat tabs

* add remove confirmation dialog

* improve add chat design

* lazy-load tabs

* create global assets store to dedupe global asset fetching

* improve tab design/styles

* update notes

* use frosty dialog

* update claude md

* tweak

* add ability to reorder tabs

* resolve conflict

* merge cleanup

* format

* update release notes

* add chat delay sync completion tracking to prevent countdown re-appearance

* add tests

* fix

* update release notes

* bump
This commit is contained in:
Tommy Chow 2026-01-01 22:28:17 -05:00 • committed by GitHub
parent b3af20d89a
commit 8f4ebde155
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
155 changed files with 16703 additions and 6729 deletions

View file

@ -6,6 +6,16 @@ Frosty is a cross-platform Flutter app for Twitch that supports third-party emot
## Critical Development Workflow
### Essential Commands
**Dependencies and Development:**
- `flutter pub get` - Install dependencies
- `flutter run` - Run the app on connected device/emulator
- `flutter build apk` - Build Android APK
- `flutter build ios` - Build iOS app
- `flutter analyze` - Run static analysis and lint checks
### Code Generation (REQUIRED)
All MobX stores and JSON models require code generation. **Always run after any MobX or model changes:**
@ -14,14 +24,19 @@ All MobX stores and JSON models require code generation. **Always run after any
flutter packages pub run build_runner build --delete-conflicting-outputs
```
Generated `.g.dart` files are excluded from linting but must be committed.
**Alternative commands:**
- `flutter packages pub run build_runner build` - Generate code once
- `flutter packages pub run build_runner watch` - Watch and regenerate code on changes
Generated `.g.dart` files are excluded from linting but must be committed to source control.
### Environment Setup
Twitch API requires client credentials via `--dart-define`:
```bash
flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_SECRET
flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET
```
## Architecture Patterns
@ -35,20 +50,50 @@ flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_SECR
### API Service Architecture
- **Shared HTTP Client**: Single `Client()` instance injected into all API services in `main.dart`
- **Shared HTTP Client**: Single Dio instance configured in `DioClient.createClient()` with:
- Connection pooling and keep-alive headers for efficiency
- Optimized timeouts: 8s connect, 15s receive, 10s send
- Global Twitch User-Agent header
- Simple logging in debug mode (not verbose to avoid log spam)
- **API Services**: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi` in `lib/apis/`
- **Headers Pattern**: Twitch API methods accept `headers` parameter for auth tokens
- **Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers:
- Detects Twitch API URLs and adds Authorization + Client-Id headers
- Updates automatically when user authentication changes
- Eliminates manual header passing throughout the codebase
- **Error Handling**: `UnauthorizedInterceptor` catches 401 errors for token refresh
- **Service Pattern**: All API services extend `BaseApiClient` for consistent error handling
### Project Structure
**Key Directories:**
- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding)
- Each screen has its own MobX stores in a `stores/` subdirectory
- `lib/models/` - Data models with JSON serialization (uses `.g.dart` files)
- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV
- `lib/widgets/` - Reusable UI components
- `lib/cache_manager.dart` - Custom cache management for images/media
- `lib/utils.dart` - Utility functions and helpers
- `lib/utils/` - Additional utility modules including context extensions
### Screen Organization
```
lib/screens/{feature}/
├── {feature}.dart # Main UI
├── {feature}_store.dart # Feature store (NOT at root)
├── {feature}_store.g.dart # Generated file
├── stores/ # Feature stores directory
│ ├── {feature}_store.dart
│ └── {feature}_store.g.dart
└── subdirectories/ # Feature sub-components
```
**Main Application Flow:**
1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider
2. Authentication handled by `AuthStore` using Twitch OAuth
3. Settings persisted via `SettingsStore` with SharedPreferences
4. API services inject common HTTP client for efficient connection reuse
### Model Generation
- **JSON Serialization**: Models use `@JsonSerializable()` with `.g.dart` companions
@ -69,12 +114,23 @@ Initialized in `main.dart` with error handling:
- **IRC Connection**: WebSocket to Twitch IRC for real-time chat
- **Emote Loading**: Asynchronous third-party emote fetching (BTTV/FFZ/7TV)
- **Message Parsing**: Custom rendering with emote/badge support
- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing
- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs
- **Message Management**: 5000 message limit with 20% batch removal optimization for performance
- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data
- **User interaction features**: Blocking, reporting, moderation capabilities
### Authentication Flow
- **OAuth**: Twitch OAuth via WebView in `AuthStore`
- **Token Storage**: FlutterSecureStorage for secure token persistence
- **Auto-refresh**: Token validation and refresh logic in `AuthStore`
- **Two-tier Token System**:
- Default app token for unauthenticated requests
- Optional user token stored in Flutter Secure Storage
- Automatic token validation on startup with fallback
- **WebView OAuth Flow**: Custom JavaScript injection in WebView for seamless Twitch login experience without opening external browsers
- **Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists
## Code Style Enforcement
@ -84,6 +140,9 @@ Analysis rules in `analysis_options.yaml`:
- Package imports: `always_use_package_imports`
- Trailing commas: `require_trailing_commas`
- Final locals: `prefer_final_locals`
- Additional rules: `directives_ordering`, `avoid_void_async`, `always_declare_return_types`
**Important**: `.g.dart` files are excluded from analysis but must be committed to source control.
## Common Patterns
@ -109,6 +168,37 @@ autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
Use `Future.error()` for API failures, not exceptions.
## MobX Store Implementation
### Store Pattern
All stores follow `StoreBase with _$StoreName` pattern:
- Generate `.g.dart` files with `flutter packages pub run build_runner build`
- Use `@observable`, `@action`, and `@computed` annotations
- Settings store uses `@JsonSerializable()` with automatic persistence via `autorun()`
### Authentication Headers
Generated as `@computed` properties that automatically update when tokens change.
### State Persistence
Settings automatically saved to SharedPreferences via MobX `autorun()` reaction.
## Custom Cache Management
- **Orphaned File Cleanup**: `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to remove files not in database
- **Optimized Settings**: 30-day stale period, 10,000 max objects for efficient image caching across the app
## Code Style Guidelines
- **Prefer modern Flutter/Dart features and type-safety**
- **Use consistent naming, comments, and styles matching the existing codebase**
- **Single quotes, trailing commas, and final locals per lint rules**
- **Package imports over relative imports**
- **Observer widgets only wrap necessary UI components for MobX optimization**
## Platform Support
- **iOS/Android**: Native platform features via plugins

2
.gitignore vendored
View file

@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
@ -65,3 +66,4 @@ random/
# ai
.claude
.cursor

118
CLAUDE.md
View file

@ -2,69 +2,131 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Workflow
**Plan Mode - In-Depth Planning**: When in plan mode, interview thoroughly before coding:
- Ask detailed questions about technical implementation, UI/UX concerns, tradeoffs, and edge cases
- Continue asking questions until all requirements and non-obvious decisions are clear
- Don't begin implementation until all important details are resolved
**Pattern Consistency**: When implementing new code:
- Search the codebase to find existing usages and implementations
- Prefer following established patterns, styles, and practices for consistency
## Development Commands
**Build and Development**
- `flutter pub get` - Install dependencies
- `flutter run` - Run the app on connected device/emulator
- `flutter build apk` - Build Android APK
- `flutter build ios` - Build iOS app
- `flutter analyze` - Run static analysis and lint checks
- `flutter test` - Run tests (note: no test suite currently exists)
**Code Generation (Required for MobX stores and JSON serialization)**
- `flutter packages pub run build_runner build` - Generate code once
- `flutter packages pub run build_runner watch` - Watch and regenerate code on changes
- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Force rebuild all generated files
**Asset Generation**
- `flutter pub run flutter_native_splash:create` - Generate native splash screens
- `flutter pub run flutter_launcher_icons:main` - Generate app launcher icons
**Environment Variables**
Use `--dart-define` to set environment variables:
- `--dart-define=clientId=YOUR_TWITCH_CLIENT_ID`
- `--dart-define=secret=YOUR_TWITCH_CLIENT_SECRET`
Use `--dart-define` to set environment variables (required for Twitch API access):
```bash
flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET
```
## Architecture Overview
**State Management**: Uses MobX for reactive state management. All stores end with `Store` and have corresponding `.g.dart` generated files. Stores are located within their respective screen directories under `stores/` folders.
**State Management**: MobX with code generation. All stores end with `Store` and have corresponding `.g.dart` generated files.
**Key Directories**:
- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding)
- Each screen has its own MobX stores in a `stores/` subdirectory
- `lib/models/` - Data models with JSON serialization (uses .g.dart files)
- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV
- `lib/widgets/` - Reusable UI components
- `lib/cache_manager.dart` - Custom cache management for images/media
- `lib/utils.dart` - Utility functions and helpers
- `lib/utils/` - Utility modules including context extensions
**Main Application Flow**:
1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider
2. Authentication handled by `AuthStore` using Twitch OAuth
3. Settings persisted via `SettingsStore` with SharedPreferences
4. API services inject common HTTP client for efficient connection reuse
3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()`
4. API services share a common Dio HTTP client for efficient connection reuse
**Screen Architecture**:
- Home screen with tabs: Following streams, Top streams, Categories, Search
- Channel screen with video player and chat functionality
- Settings screen with account management and app configuration
- Onboarding flow for first-time users
**Global Stores** (provided at app root in `main.dart`):
**Chat System**:
- Real-time chat via WebSocket connection to Twitch IRC
- Third-party emote support (BTTV, FFZ, 7TV) loaded asynchronously
- Chat message parsing and rendering with emote/badge support
- User interaction features (blocking, reporting, moderation)
- `AuthStore` - Authentication state and token management
- `SettingsStore` - User preferences with automatic persistence
**Third-party Integrations**:
- Firebase: Crashlytics, Performance monitoring, Analytics
- Twitch API: Authentication, streams, users, chat
- BTTV/FFZ/7TV APIs: Extended emote and badge support
## HTTP Client Architecture
## Development Notes
**Centralized Dio Setup**: All API services share a single Dio instance configured in `DioClient.createClient()` with connection pooling, keep-alive headers, and optimized timeouts (8s connect, 15s receive, 10s send).
**MobX Code Generation**: This project heavily uses MobX for state management and JSON serialization. Any changes to MobX stores or model classes with `@JsonSerializable()` require running the build_runner to regenerate the corresponding `.g.dart` files. Generated files are excluded from analysis in `analysis_options.yaml`.
**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. Updates automatically when user authentication changes.
**API Configuration**: Requires Twitch Client ID and Client Secret from dev.twitch.tv console for API access. Use `--dart-define` flags when running the app.
**API Service Pattern**: All API services extend `BaseApiClient` for consistent error handling. Services: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`.
**Code Style**: The project enforces specific lint rules including single quotes, trailing commas, final locals, and package imports. Run `flutter analyze` to check for violations.
## Authentication & Token Management
**Dependency Injection**: Uses Provider for dependency injection, with all major services (API clients, stores) provided at the app root in `main.dart` with a shared HTTP client for efficiency.
**Two-tier Token System**:
**Platform Support**: iOS and Android with platform-specific configurations. Uses Firebase for crashlytics, performance monitoring, and analytics.
- Default app token for unauthenticated requests
- Optional user token stored in Flutter Secure Storage
- Automatic token validation on startup with fallback
**Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists.
## MobX Store Implementation
**Store Pattern**: All stores follow `StoreBase with _$StoreName` pattern:
```dart
class SomeStore = SomeStoreBase with _$SomeStore;
abstract class SomeStoreBase with Store {
@observable
var someValue = '';
@action
void updateValue(String value) => someValue = value;
@computed
String get derivedValue => someValue.toUpperCase();
}
```
After any changes to MobX stores or `@JsonSerializable()` models, run the build_runner to regenerate `.g.dart` files.
## Chat System Architecture
- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing
- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs
- **Message Management**: 5000 message limit with 20% batch removal optimization
- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data
## Code Style
Analysis rules enforced via `analysis_options.yaml`:
- `prefer_single_quotes` - Use single quotes for strings
- `always_use_package_imports` - Package imports over relative imports
- `require_trailing_commas` - Trailing commas on multi-line constructs
- `prefer_final_locals` - Final for local variables
- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`
Generated `.g.dart` files are excluded from analysis but must be committed.
## Commit Convention
Commits follow conventional format: `refactor:`, `feat:`, `fix:`, `format:`, etc. Keep commits tightly scoped. Include generated `.g.dart` outputs in the same commit as their source changes.

View file

@ -1,35 +0,0 @@
# GEMINI.md
## Project Overview
This project is a Flutter-based mobile application called "Frosty", a third-party client for the streaming platform Twitch. It aims to provide a better user experience by integrating with popular third-party services like BetterTTV (BTTV), FrankerFaceZ (FFZ), and 7TV, which are not supported by the official Twitch app.
The application is built using the Flutter framework and the Dart programming language. It uses Firebase for analytics and crash reporting, and MobX for state management. The app is available for both Android and iOS.
## Building and Running
To build and run the project, you will need to have Flutter installed.
1. **Clone the repository:**
```bash
git clone https://github.com/tommyxchow/frosty.git
```
2. **Set up Twitch API credentials:**
* Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
* Use `--dart-define` to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**.
3. **Install dependencies:**
```bash
flutter pub get
```
4. **Run the app:**
```bash
flutter run
```
## Development Conventions
* **State Management:** The project uses MobX for state management. Code generation is required for MobX stores.
* **Code Style:** The project uses the `flutter_lints` package to enforce good coding practices.
* **Dependencies:** Dependencies are managed using the `pubspec.yaml` file.
* **Firebase:** The project uses Firebase for analytics and crash reporting. A `firebase_options.dart` file is used to configure the Firebase integration.
* **API Integration:** The application interacts with the Twitch API and other third-party APIs (BTTV, FFZ, 7TV) to fetch data.

View file

@ -11,6 +11,11 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
- .dart_tool/**
- build/**
- ios/**
- android/**
- web/**
linter:
# The lint rules applied to this project can be customized in the

View file

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

View file

@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.2.1" apply false
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.firebase-perf" version "1.4.2" apply false
id "com.google.firebase.crashlytics" version "2.9.9" apply false

View file

@ -1,77 +1,30 @@
## v4.9.6
## v5.0.0
June 14, 2025
### Bug Fixes
- [Android] Fix missing stream quality options
---
## v4.9.5
June 13, 2025
### Bug Fixes
- Fix stream latency no longer updating
---
## v4.9.4
June 6, 2025
### Bug Fixes
- Fix video stats blocking when using custom overlay
---
## v4.9.3
May 26, 2025
### Bug Fixes
- Fix missing 7TV emotes on some channels
- Fix sleep timer menu border styling
---
## v4.9.2
April 7, 2025
### Bug Fixes
- Fix large cache size (by @Artiu on GitHub)
---
## v4.9.1
April 4, 2025
### Bug Fixes
- [Android] Fix overlay latency and stream qualities, persist last chosen stream quality (by @Artiu on GitHub)
---
## v4.9.0
December 30, 2024
January 1, 2026
### New Features
- [Android] Added new "Use enhanced rendering" option in video settings
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
- Resize the chat and video areas in landscape mode by dragging the divider.
- A new section in the Following tab shows which of your followed channels are currently offline.
- Change your chat username color from the chat details menu.
- A new randomize button for the accent color theme in settings.
- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures.
- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub).
### Improvements
- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit.
- Redesigned the video overlay and added stream details to the app bar in chat-only mode.
- Networking and data handling have been completely rewritten to be faster and more reliable.
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
### Bug Fixes
- Fixed incorrect badge order and spacing for FFZ bots
- Fixed missing emotes on channels that don't have 7TV enabled
- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
- Fixed missing badges and emotes in historical messages upon initial connection.
- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode.
- Fixed chat reconnection issues when a chat delay was active.
- Fixed duplicate message cases and various UI stability issues.
#### See all release notes at https://github.com/tommyxchow/frosty/releases
---
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases)

View file

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
</dict>
</plist>

View file

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View file

@ -3,129 +3,130 @@ PODS:
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/Analytics (11.10.0):
- Firebase/Core
- Firebase/Core (11.10.0):
- Firebase/CoreOnly (12.2.0):
- FirebaseCore (~> 12.2.0)
- Firebase/Crashlytics (12.2.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 11.10.0)
- Firebase/CoreOnly (11.10.0):
- FirebaseCore (~> 11.10.0)
- Firebase/Crashlytics (11.10.0):
- FirebaseCrashlytics (~> 12.2.0)
- Firebase/Performance (12.2.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 11.10.0)
- Firebase/Performance (11.10.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 11.10.0)
- firebase_analytics (11.4.6):
- Firebase/Analytics (= 11.10.0)
- FirebasePerformance (~> 12.2.0)
- firebase_analytics (12.0.1):
- firebase_core
- FirebaseAnalytics (= 12.2.0)
- Flutter
- firebase_core (4.1.0):
- Firebase/CoreOnly (= 12.2.0)
- Flutter
- firebase_crashlytics (5.0.1):
- Firebase/Crashlytics (= 12.2.0)
- firebase_core
- Flutter
- firebase_core (3.13.1):
- Firebase/CoreOnly (= 11.10.0)
- Flutter
- firebase_crashlytics (4.3.6):
- Firebase/Crashlytics (= 11.10.0)
- firebase_performance (0.11.0-1):
- Firebase/Performance (= 12.2.0)
- firebase_core
- Flutter
- firebase_performance (0.10.1-6):
- Firebase/Performance (= 11.10.0)
- firebase_core
- Flutter
- FirebaseABTesting (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseAnalytics (11.10.0):
- FirebaseAnalytics/AdIdSupport (= 11.10.0)
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseABTesting (12.2.0):
- FirebaseCore (~> 12.2.0)
- FirebaseAnalytics (12.2.0):
- FirebaseAnalytics/Default (= 12.2.0)
- FirebaseCore (~> 12.2.0)
- FirebaseInstallations (~> 12.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseAnalytics/Default (12.2.0):
- FirebaseCore (~> 12.2.0)
- FirebaseInstallations (~> 12.2.0)
- GoogleAppMeasurement/Default (= 12.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (11.10.0):
- FirebaseCoreInternal (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseCoreInternal (11.10.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseCrashlytics (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0)
- FirebaseSessions (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- FirebaseCore (12.2.0):
- FirebaseCoreInternal (~> 12.2.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.2.0):
- FirebaseCore (~> 12.2.0)
- FirebaseCoreInternal (12.2.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseCrashlytics (12.2.0):
- FirebaseCore (~> 12.2.0)
- FirebaseInstallations (~> 12.2.0)
- FirebaseRemoteConfigInterop (~> 12.2.0)
- FirebaseSessions (~> 12.2.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (11.10.0):
- FirebaseCore (~> 11.10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- FirebaseInstallations (12.2.0):
- FirebaseCore (~> 12.2.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebasePerformance (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfig (~> 11.0)
- FirebaseSessions (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- FirebasePerformance (12.2.0):
- FirebaseCore (~> 12.2.0)
- FirebaseInstallations (~> 12.2.0)
- FirebaseRemoteConfig (~> 12.2.0)
- FirebaseSessions (~> 12.2.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (11.10.0):
- FirebaseABTesting (~> 11.0)
- FirebaseCore (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0)
- FirebaseSharedSwift (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseRemoteConfigInterop (11.13.0)
- FirebaseSessions (11.10.0):
- FirebaseCore (~> 11.10.0)
- FirebaseCoreExtension (~> 11.10.0)
- FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- FirebaseRemoteConfig (12.2.0):
- FirebaseABTesting (~> 12.2.0)
- FirebaseCore (~> 12.2.0)
- FirebaseInstallations (~> 12.2.0)
- FirebaseRemoteConfigInterop (~> 12.2.0)
- FirebaseSharedSwift (~> 12.2.0)
- GoogleUtilities/Environment (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseRemoteConfigInterop (12.2.0)
- FirebaseSessions (12.2.0):
- FirebaseCore (~> 12.2.0)
- FirebaseCoreExtension (~> 12.2.0)
- FirebaseInstallations (~> 12.2.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (11.13.0)
- FirebaseSharedSwift (12.2.0)
- Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleAppMeasurement (11.10.0):
- GoogleAppMeasurement/AdIdSupport (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- GoogleAdsOnDeviceConversion (2.3.0):
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.10.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- GoogleAppMeasurement/Core (12.2.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.10.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- GoogleAppMeasurement/Default (12.2.0):
- GoogleAdsOnDeviceConversion (= 2.3.0)
- GoogleAppMeasurement/Core (= 12.2.0)
- GoogleAppMeasurement/IdentitySupport (= 12.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.2.0):
- GoogleAppMeasurement/Core (= 12.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
@ -220,6 +221,7 @@ SPEC REPOS:
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
@ -266,30 +268,31 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
firebase_analytics: 943aa4699bf6afe056a96991ba8ee34776323d4c
firebase_core: 3c2f323cae65c97a636a05a23b17730ef93df2cf
firebase_crashlytics: 6f5f71ba7ec7b6a0412c4ed076272643c434f69f
firebase_performance: da79b34e2753057f7028c5e41ac5d8b3c5398eb2
FirebaseABTesting: dfc10eb6cc08fe3b391ac9e5aa40396d43ea6675
FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef
FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac
FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
FirebaseCrashlytics: 84b073c997235740e6a951b7ee49608932877e5c
FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
FirebasePerformance: f0bd14be05aaa1136cbb1c9aaaf87d213d0e6fbf
FirebaseRemoteConfig: 10695bc0ce3b103e3706a5578c43f2a9f69d5aaa
FirebaseRemoteConfigInterop: 7915cec47731a806cda541f90898ad0fab8f9f86
FirebaseSessions: 9b3b30947b97a15370e0902ee7a90f50ef60ead6
FirebaseSharedSwift: aca73668bc95e8efccb618e0167eab05d19d3a75
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1
firebase_analytics: cad607594fc56b3743691f37000d81cde0a86237
firebase_core: 3b9065a1037f7f2e360077d2aa5fb6ec9adb3005
firebase_crashlytics: 0d84e61860fcc67faf4d3a59664ce13a5622dbc0
firebase_performance: 0e63765dc819d5f4b2331000c4f42382fc92a739
FirebaseABTesting: 32f3fc079d72c9b93e000b60877c4e4f62ef7031
FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8
FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd
FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5
FirebaseCoreInternal: 56ea29f3dad2894f81b060f706f9d53509b6ed3b
FirebaseCrashlytics: f83cbf176d5c637ade108c0aacf1ccbd5ec499bf
FirebaseInstallations: 3e884b01feabdf67582a80f3250425a00979b4ed
FirebasePerformance: 9071eb99f3ed135defc1c145391a82edb98e0b93
FirebaseRemoteConfig: e3ae5500b92e813f3a0b12fa09701a26441dfc95
FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1
FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737
FirebaseSharedSwift: 52d868d4c269fcb4e4e1310c548435a9c1f46e25
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d
GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe
GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
@ -301,6 +304,6 @@ SPEC CHECKSUMS:
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
COCOAPODS: 1.16.2

View file

@ -0,0 +1,248 @@
import 'package:dio/dio.dart';
/// Common type aliases to reduce repetition
typedef JsonMap = Map<String, dynamic>;
typedef JsonList = List<dynamic>;
typedef QueryParams = Map<String, dynamic>;
typedef ApiHeaders = Map<String, String>;
/// Custom API exceptions for better error handling
class ApiException implements Exception {
final String message;
final int? statusCode;
final DioExceptionType? type;
const ApiException(this.message, [this.statusCode, this.type]);
@override
String toString() => 'ApiException: $message';
}
class NetworkException extends ApiException {
const NetworkException(String message)
: super(message, null, DioExceptionType.connectionError);
}
class TimeoutException extends ApiException {
const TimeoutException(String message)
: super(message, null, DioExceptionType.connectionTimeout);
}
class ServerException extends ApiException {
const ServerException(String message, int statusCode)
: super(message, statusCode, DioExceptionType.badResponse);
}
class NotFoundException extends ApiException {
const NotFoundException(String message)
: super(message, 404, DioExceptionType.badResponse);
}
class UnauthorizedException extends ApiException {
const UnauthorizedException(String message)
: super(message, 401, DioExceptionType.badResponse);
}
/// Base API client to eliminate code duplication across API services
abstract class BaseApiClient {
final Dio _dio;
final String baseUrl;
const BaseApiClient(this._dio, this.baseUrl);
/// Builds the full URL, handling both relative endpoints and full URLs
String _buildUrl(String endpoint) {
// If endpoint is already a full URL (starts with http:// or https://), use it as-is
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
// Otherwise, concatenate with base URL
return '$baseUrl$endpoint';
}
/// Generic GET request with common error handling
Future<T> get<T>(
String endpoint, {
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.get(
url,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic POST request with common error handling
Future<T> post<T>(
String endpoint, {
dynamic data,
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.post(
url,
data: data,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic PUT request with common error handling
Future<T> put<T>(
String endpoint, {
dynamic data,
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.put(
url,
data: data,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Generic DELETE request with common error handling
Future<T> delete<T>(
String endpoint, {
QueryParams? queryParameters,
ApiHeaders? headers,
CancelToken? cancelToken,
}) async {
try {
final url = _buildUrl(endpoint);
final response = await _dio.delete(
url,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
/// Check if response indicates success (useful for status-only operations)
bool isSuccessResponse(Response response) {
return response.statusCode != null &&
response.statusCode! >= 200 &&
response.statusCode! < 300;
}
/// Converts DioException to appropriate ApiException subtype
ApiException _handleError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException(_getTimeoutMessage(error.type));
case DioExceptionType.connectionError:
return NetworkException(
'No internet connection. Please check your network.',
);
case DioExceptionType.badResponse:
return _handleHttpError(error);
case DioExceptionType.cancel:
return ApiException('Request was cancelled');
case DioExceptionType.badCertificate:
return ApiException('Security certificate error');
case DioExceptionType.unknown:
return ApiException(error.message ?? 'An unexpected error occurred');
}
}
/// Gets specific timeout message based on timeout type
String _getTimeoutMessage(DioExceptionType type) {
switch (type) {
case DioExceptionType.connectionTimeout:
return 'Connection timeout. Please try again.';
case DioExceptionType.sendTimeout:
return 'Upload timeout. Please try again.';
case DioExceptionType.receiveTimeout:
return 'Server response timeout. Please try again.';
default:
return 'Request timeout. Please try again.';
}
}
/// Handles HTTP status code errors with specific exception types
ApiException _handleHttpError(DioException error) {
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
// Extract error message from server response
String? serverMessage;
if (responseData is JsonMap) {
serverMessage =
responseData['message'] as String? ??
responseData['error'] as String? ??
responseData['error_description'] as String?;
}
switch (statusCode) {
case 400:
return ApiException(serverMessage ?? 'Invalid request data');
case 401:
return UnauthorizedException('Please log in to continue');
case 403:
return ApiException('Access denied. Insufficient permissions');
case 404:
return NotFoundException(serverMessage ?? 'Resource not found');
case 409:
return ApiException('Conflict with current state. Please refresh');
case 422:
return ApiException(serverMessage ?? 'Invalid data provided');
case 429:
return ApiException(
'Rate limit exceeded. Please wait before trying again',
);
case 500:
return ServerException('Server error. Please try again later', 500);
case 502:
return ServerException('Service temporarily unavailable', 502);
case 503:
return ServerException(
'Service maintenance. Please try again later',
503,
);
case 504:
return ServerException('Server timeout. Please try again', 504);
default:
return ServerException(
serverMessage ?? 'HTTP Error $statusCode occurred',
statusCode ?? 500,
);
}
}
}

View file

@ -1,75 +1,55 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The BTTV service for making API calls.
class BTTVApi {
final Client _client;
const BTTVApi(this._client);
class BTTVApi extends BaseApiClient {
BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached');
/// Returns a map of global BTTV emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global');
final data = await get<JsonList>('/emotes/global');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body) as List;
final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList();
return emotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
.toList();
} else {
return Future.error('Failed to get BTTV global emotes');
}
}
/// Returns a map of a channel's BTTV emotes to their URL.
Future<List<Emote>> getEmotesChannel({required String id}) async {
final url =
Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
final data = await get<JsonMap>('/users/twitch/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final result = EmoteBTTVChannel.fromJson(decoded);
final result = EmoteBTTVChannel.fromJson(data);
final emoteToUrl = <Emote>[];
emoteToUrl.addAll(
result.channelEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
result.channelEmotes.map(
(emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel),
),
);
emoteToUrl.addAll(
result.sharedEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
result.sharedEmotes.map(
(emote) => Emote.fromBTTV(emote, EmoteType.bttvShared),
),
);
return emoteToUrl;
} else {
return Future.error('Failed to get BTTV channel emotes');
}
}
/// Returns a map of username to their BTTV badge.
Future<Map<String, ChatBadge>> getBadges() async {
final url = Uri.parse('https://api.betterttv.net/3/cached/badges');
final data = await get<JsonList>('/badges');
final response = await _client.get(url);
if (response.statusCode == 200) {
final badges = jsonDecode(response.body) as List;
final badgeObjects =
badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
final badgeObjects = data
.map((badge) => BadgeInfoBTTV.fromJson(badge))
.toList();
return {
for (final badge in badgeObjects)
badge.providerId: ChatBadge.fromBTTV(badge),
};
} else {
return Future.error('Failed to get BTTV badges');
}
}
}

67
lib/apis/dio_client.dart Normal file
View file

@ -0,0 +1,67 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// Centralized Dio client configuration with interceptors and error handling
class DioClient {
static Dio createClient({List<Interceptor>? additionalInterceptors}) {
final dio = Dio();
// Optimized configuration based on 2024-2025 best practices
dio.options = BaseOptions(
connectTimeout: const Duration(
seconds: 8,
), // Balanced for various networks
receiveTimeout: const Duration(
seconds: 15,
), // Allow for larger API responses
sendTimeout: const Duration(seconds: 10), // Reasonable for uploads
headers: {
'User-Agent': 'Frosty (Flutter Twitch Client)',
'Accept': 'application/json',
'Content-Type': 'application/json',
'Connection': 'keep-alive', // Enable connection reuse
},
followRedirects: true,
maxRedirects: 3, // Reduced for better performance
persistentConnection: true, // Enable connection pooling
);
// Simple logging for debug builds
if (kDebugMode) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
debugPrint('→ ${options.method} ${options.uri}');
handler.next(options);
},
onResponse: (response, handler) {
final statusCode = response.statusCode ?? 0;
final emoji = statusCode >= 200 && statusCode < 300 ? '✅' : '❌';
debugPrint(
'$emoji ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.uri}',
);
handler.next(response);
},
onError: (error, handler) {
debugPrint(
'❌ ${error.type.name} ${error.requestOptions.method} ${error.requestOptions.uri}',
);
if (error.response != null) {
debugPrint(' Status: ${error.response!.statusCode}');
}
handler.next(error);
},
),
);
}
// Add any additional interceptors provided
if (additionalInterceptors != null) {
for (final interceptor in additionalInterceptors) {
dio.interceptors.add(interceptor);
}
}
return dio;
}
}

View file

@ -1,49 +1,36 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The FFZ service for making API calls.
class FFZApi {
final Client _client;
const FFZApi(this._client);
class FFZApi extends BaseApiClient {
FFZApi(Dio dio) : super(dio, 'https://api.frankerfacez.com/v1');
/// Returns a map of global FFZ emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final url = Uri.parse('https://api.frankerfacez.com/v1/set/global');
final data = await get<JsonMap>('/set/global');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final defaultSets = decoded['default_sets'] as List;
final defaultSets = data['default_sets'] as JsonList;
final emotes = <EmoteFFZ>[];
for (final setId in defaultSets) {
final emoticons =
decoded['sets'][setId.toString()]['emoticons'] as List;
final emoticons = data['sets'][setId.toString()]['emoticons'] as JsonList;
emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote)));
}
return emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal))
.toList();
} else {
return Future.error('Failed to get FFZ global emotes');
}
}
/// Returns a channel's FFZ room info including custom badges and emote used.
Future<(RoomFFZ, List<Emote>)> getRoomInfo({required String id}) async {
final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id');
final data = await get<JsonMap>('/room/id/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final roomInfo = RoomFFZ.fromJson(decoded['room']);
final roomInfo = RoomFFZ.fromJson(data['room']);
final emoticons =
decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList;
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
@ -51,27 +38,22 @@ class FFZApi {
roomInfo,
emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
.toList()
.toList(),
);
} else {
return Future.error('Failed to get FFZ room info');
}
}
/// Returns a map of badges user IDs to a list of their FFZ badges.
Future<Map<String, List<ChatBadge>>> getBadges() async {
final url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids');
final data = await get<JsonMap>('/badges/ids');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final badges = decoded['badges'] as List;
final badgeObjects =
badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList();
final badges = data['badges'] as JsonList;
final badgeObjects = badges
.map((badge) => BadgeInfoFFZ.fromJson(badge))
.toList();
final result = <String, List<ChatBadge>>{};
for (final badge in badgeObjects.reversed) {
for (final userId in decoded['users'][badge.id.toString()]) {
for (final userId in data['users'][badge.id.toString()]) {
final entry = result[userId.toString()];
final normalBadge = ChatBadge.fromFFZ(badge);
if (entry == null) {
@ -83,8 +65,5 @@ class FFZApi {
}
return result;
} else {
return Future.error('Failed to get FFZ badges');
}
}
}

View file

@ -1,52 +1,39 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/emotes.dart';
import 'package:http/http.dart';
/// The 7TV service for making API calls.
class SevenTVApi {
final Client _client;
const SevenTVApi(this._client);
class SevenTVApi extends BaseApiClient {
SevenTVApi(Dio dio) : super(dio, 'https://7tv.io/v3');
/// Returns a map of global 7TV emotes to their URL.
Future<List<Emote>> getEmotesGlobal() async {
final url = Uri.parse('https://7tv.io/v3/emote-sets/global');
final data = await get<JsonMap>('/emote-sets/global');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body)['emotes'] as List;
final decoded = data['emotes'] as JsonList;
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
return emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal))
.toList();
} else {
return Future.error('Failed to get 7TV global emotes');
}
}
/// Returns a tuple containing the emote set ID and a map of a channel's 7TV
/// emotes to their URL.
Future<(String, List<Emote>)> getEmotesChannel({required String id}) async {
final url = Uri.parse('https://7tv.io/v3/users/twitch/$id');
final data = await get<JsonMap>('/users/twitch/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final emoteSetId = decoded['emote_set']['id'] as String;
final emotes = (decoded['emote_set']['emotes'] as List)
.map((emote) => Emote7TV.fromJson(emote));
final emoteSetId = data['emote_set']['id'] as String;
final emotes = (data['emote_set']['emotes'] as JsonList).map(
(emote) => Emote7TV.fromJson(emote),
);
return (
emoteSetId,
emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel))
.where((emote) => emote.url.isNotEmpty)
.toList()
.toList(),
);
} else {
return Future.error('Failed to get 7TV channel emotes');
}
}
}

View file

@ -1,55 +1,46 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/models/shared_chat_session.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/models/user.dart';
import 'package:http/http.dart';
/// The Twitch service for making API calls.
class TwitchApi {
final Client _client;
class TwitchApi extends BaseApiClient {
static const String _helixBaseUrl = 'https://api.twitch.tv/helix';
static const String _oauthBaseUrl = 'https://id.twitch.tv/oauth2';
static const String _recentMessagesUrl =
'https://recent-messages.robotty.de/api/v2';
const TwitchApi(this._client);
TwitchApi(Dio dio) : super(dio, _helixBaseUrl);
/// Returns a list of all Twitch global emotes.
Future<List<Emote>> getEmotesGlobal({
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global');
final response = await _client.get(url, headers: headers);
Future<List<Emote>> getEmotesGlobal() async {
final data = await get<JsonMap>('/chat/emotes/global');
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body)['data'] as List;
final emotes =
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final decoded = data['data'] as JsonList;
final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
return emotes
.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal))
.toList();
} else {
return Future.error('Failed to get Twitch global emotes');
}
}
/// Returns a list of a channel's Twitch emotes given their [id].
Future<List<Emote>> getEmotesChannel({
required String id,
required Map<String, String> headers,
}) async {
final url =
Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id');
Future<List<Emote>> getEmotesChannel({required String id}) async {
final data = await get<JsonMap>(
'/chat/emotes',
queryParameters: {'broadcaster_id': id},
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body)['data'] as List;
final emotes =
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final decoded = data['data'] as JsonList;
final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
return emotes.map((emote) {
switch (emote.emoteType) {
@ -63,25 +54,17 @@ class TwitchApi {
return Emote.fromTwitch(emote, EmoteType.twitchChannel);
}
}).toList();
} else {
return Future.error('Failed to get Twitch channel emotes');
}
}
/// Returns a list of Twitch emotes under the provided [setId].
Future<List<Emote>> getEmotesSets({
required String setId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId',
Future<List<Emote>> getEmotesSets({required String setId}) async {
final data = await get<JsonMap>(
'/chat/emotes/set',
queryParameters: {'emote_set_id': setId},
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body)['data'] as List;
final emotes =
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final decoded = data['data'] as JsonList;
final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
return emotes.map((emote) {
switch (emote.emoteType) {
@ -94,25 +77,18 @@ class TwitchApi {
return Emote.fromTwitch(emote, EmoteType.twitchUnlocked);
}
}).toList();
} else {
return Future.error('Failed to get Twitch emotes set');
}
}
/// Returns a map of global Twitch badges to their [Emote] object.
Future<Map<String, ChatBadge>> getBadgesGlobal({
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global');
Future<Map<String, ChatBadge>> getBadgesGlobal() async {
final data = await get<JsonMap>('/chat/badges/global');
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final result = <String, ChatBadge>{};
final decoded = jsonDecode(response.body)['data'] as List;
final decoded = data['data'] as JsonList;
for (final badge in decoded) {
final id = badge['set_id'] as String;
final versions = badge['versions'] as List;
final versions = badge['versions'] as JsonList;
for (final version in versions) {
final badgeInfo = BadgeInfoTwitch.fromJson(version);
@ -121,27 +97,21 @@ class TwitchApi {
}
return result;
} else {
return Future.error('Failed to get Twitch global badges');
}
}
/// Returns a map of a channel's Twitch badges to their [Emote] object.
Future<Map<String, ChatBadge>> getBadgesChannel({
required String id,
required Map<String, String> headers,
}) async {
final url =
Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id');
Future<Map<String, ChatBadge>> getBadgesChannel({required String id}) async {
final data = await get<JsonMap>(
'/chat/badges',
queryParameters: {'broadcaster_id': id},
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final result = <String, ChatBadge>{};
final decoded = jsonDecode(response.body)['data'] as List;
final decoded = data['data'] as JsonList;
for (final badge in decoded) {
final id = badge['set_id'] as String;
final versions = badge['versions'] as List;
final versions = badge['versions'] as JsonList;
for (final version in versions) {
final badgeInfo = BadgeInfoTwitch.fromJson(version);
@ -150,31 +120,21 @@ class TwitchApi {
}
return result;
} else {
return Future.error('Failed to get Twitch channel badges');
}
}
/// Returns the user's info given their token through [headers].
Future<UserTwitch> getUserInfo({required Map<String, String> headers}) async {
final url = Uri.parse('https://api.twitch.tv/helix/users');
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final userData = jsonDecode(response.body)['data'] as List;
/// Returns the user's info given their token.
Future<UserTwitch> getUserInfo() async {
final data = await get<JsonMap>('/users');
final userData = data['data'] as JsonList;
return UserTwitch.fromJson(userData.first);
} else {
return Future.error('Failed to get Twitch user info');
}
}
/// Returns a token for an anonymous user.
Future<String> getDefaultToken() async {
final url = Uri(
scheme: 'https',
host: 'id.twitch.tv',
path: '/oauth2/token',
// Use custom base URL for OAuth
final data = await post<JsonMap>(
'$_oauthBaseUrl/token',
queryParameters: {
'client_id': clientId,
'client_secret': secret,
@ -182,306 +142,217 @@ class TwitchApi {
},
);
final response = await _client.post(url);
if (response.statusCode == 200) {
return jsonDecode(response.body)['access_token'];
} else {
return Future.error('Failed to get default token');
}
return data['access_token'] as String;
}
/// Returns a bool indicating the validity of the given token.
Future<bool> validateToken({required String token}) async {
final url = Uri.parse('https://id.twitch.tv/oauth2/validate');
final response =
await _client.get(url, headers: {'Authorization': 'Bearer $token'});
if (response.statusCode == 200) {
try {
await get<JsonMap>(
'$_oauthBaseUrl/validate',
headers: {'Authorization': 'Bearer $token'},
);
return true;
} else {
} on UnauthorizedException {
// 401 -> token is invalid/expired (propagated from interceptor for validate requests)
return false;
} on ApiException catch (e) {
// Network/timeout/server errors -> treat as indeterminate, not invalid
debugPrint('Token validation indeterminate: $e');
rethrow;
}
}
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
Future<StreamsTwitch> getTopStreams({
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/streams'
: 'https://api.twitch.tv/helix/streams?after=$cursor',
Future<StreamsTwitch> getTopStreams({String? cursor}) async {
final data = await get<JsonMap>(
'/streams',
queryParameters: cursor != null ? {'after': cursor} : null,
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get top streams: ${decoded['message']}');
}
return StreamsTwitch.fromJson(data);
}
/// Returns a [StreamsTwitch] object that contains the given user ID's top 20 followed streams and a cursor for further requests.
Future<StreamsTwitch> getFollowedStreams({
required String id,
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/streams/followed?user_id=$id'
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor',
final queryParams = {'user_id': id};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/streams/followed',
queryParameters: queryParams,
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error(
'Failed to get followed streams: ${decoded['message']}',
);
return StreamsTwitch.fromJson(data);
}
/// Returns a [FollowedChannels] object containing all followed channels (including offline ones) for the given user ID.
Future<FollowedChannels> getFollowedChannels({
required String userId,
String? cursor,
}) async {
final queryParams = {'user_id': userId, 'first': '20'};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/channels/followed',
queryParameters: queryParams,
);
return FollowedChannels.fromJson(data);
}
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
Future<StreamsTwitch> getStreamsUnderCategory({
required String gameId,
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/streams?game_id=$gameId'
: 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor',
);
final queryParams = {'game_id': gameId};
if (cursor != null) queryParams['after'] = cursor;
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error(
'Failed to get streams under category: ${decoded['message']}',
);
}
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
return StreamsTwitch.fromJson(data);
}
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
Future<StreamTwitch> getStream({
required String userLogin,
required Map<String, String> headers,
}) async {
final uri =
Uri.parse('https://api.twitch.tv/helix/streams?user_login=$userLogin');
final response = await _client.get(uri, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;
if (data.isNotEmpty) {
return StreamTwitch.fromJson(data.first);
} else {
return Future.error('$userLogin is offline');
}
} else {
return Future.error('Failed to get stream info');
}
}
Future<StreamsTwitch> getStreamsByIds({
required List<String> userIds,
required Map<String, String> headers,
}) async {
final uri = Uri.parse(
'https://api.twitch.tv/helix/streams?${userIds.map((e) => 'user_id=$e').join('&')}&first=100',
Future<StreamTwitch> getStream({required String userLogin}) async {
final data = await get<JsonMap>(
'/streams',
queryParameters: {'user_login': userLogin},
);
final response = await _client.get(uri, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return StreamsTwitch.fromJson(decoded);
final streamData = data['data'] as JsonList;
if (streamData.isNotEmpty) {
return StreamTwitch.fromJson(streamData.first);
} else {
return Future.error('Failed to get stream info');
throw ApiException('$userLogin is offline', 404);
}
}
Future<StreamsTwitch> getStreamsByIds({required List<String> userIds}) async {
// Create query string manually for multiple user_id parameters
final userIdParams = userIds.map((id) => 'user_id=$id').join('&');
final url = '/streams?$userIdParams&first=100';
final data = await get<JsonMap>(url);
return StreamsTwitch.fromJson(data);
}
/// Returns a [UserTwitch] object containing the user info associated with the given [userLogin].
Future<UserTwitch> getUser({
String? userLogin,
String? id,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
id != null
? 'https://api.twitch.tv/helix/users?id=$id'
: 'https://api.twitch.tv/helix/users?login=$userLogin',
);
Future<UserTwitch> getUser({String? userLogin, String? id}) async {
final queryParams = <String, String>{};
if (id != null) {
queryParams['id'] = id;
} else if (userLogin != null) {
queryParams['login'] = userLogin;
}
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
final userData = decoded['data'] as List;
final data = await get<JsonMap>('/users', queryParameters: queryParams);
final userData = data['data'] as JsonList;
if (userData.isNotEmpty) {
return UserTwitch.fromJson(userData.first);
} else {
return Future.error('User does not exist');
}
} else {
return Future.error('Failed to get user: ${decoded['message']}');
throw NotFoundException('User does not exist');
}
}
/// Returns a [Channel] object containing a channels's info associated with the given [userId].
Future<Channel> getChannel({
required String userId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId',
Future<Channel> getChannel({required String userId}) async {
final data = await get<JsonMap>(
'/channels',
queryParameters: {'broadcaster_id': userId},
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
final channelData = decoded['data'] as List;
final channelData = data['data'] as JsonList;
if (channelData.isNotEmpty) {
return Channel.fromJson(channelData.first);
} else {
return Future.error('Channel does not exist');
}
} else {
return Future.error('Failed to get channel: ${decoded['message']}');
throw ApiException('Channel does not exist', 404);
}
}
/// Returns a list of [ChannelQuery] objects closest matching the given [query].
Future<List<ChannelQuery>> searchChannels({
required String query,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/search/channels?first=8&query=$query',
Future<List<ChannelQuery>> searchChannels({required String query}) async {
final data = await get<JsonMap>(
'/search/channels',
queryParameters: {'first': '8', 'query': query},
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final channelData = jsonDecode(response.body)['data'] as List;
final channelData = data['data'] as JsonList;
return channelData.map((e) => ChannelQuery.fromJson(e)).toList();
} else {
return Future.error('Failed to get channels');
}
}
/// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests.
Future<CategoriesTwitch> getTopCategories({
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/games/top'
: 'https://api.twitch.tv/helix/games/top?after=$cursor',
Future<CategoriesTwitch> getTopCategories({String? cursor}) async {
final data = await get<JsonMap>(
'/games/top',
queryParameters: cursor != null ? {'after': cursor} : null,
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error(
'Failed to get top categories: ${decoded['message']}',
);
}
return CategoriesTwitch.fromJson(data);
}
/// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId].
Future<CategoriesTwitch> getCategory({
required Map<String, String> headers,
required String gameId,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId');
Future<CategoriesTwitch> getCategory({required String gameId}) async {
final data = await get<JsonMap>('/games', queryParameters: {'id': gameId});
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get category: ${decoded['message']}');
}
return CategoriesTwitch.fromJson(data);
}
/// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests.
Future<CategoriesTwitch> searchCategories({
required Map<String, String> headers,
required String query,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query'
: 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor',
final queryParams = {'first': '8', 'query': query};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/search/categories',
queryParameters: queryParams,
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get categories');
}
return CategoriesTwitch.fromJson(data);
}
/// Returns the sub count associated with the given [userId].
Future<int> getSubscriberCount({
required String userId,
required Map<String, String> headers,
}) async {
final uri = Uri.parse(
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId',
Future<int> getSubscriberCount({required String userId}) async {
final data = await get<JsonMap>(
'/subscriptions',
queryParameters: {'broadcaster_id': userId},
);
final response = await _client.get(uri, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return decoded['total'] as int;
} else {
return Future.error('Failed to get sub count');
}
return data['total'] as int;
}
/// Returns a user's list of blocked users given their id.
Future<List<UserBlockedTwitch>> getUserBlockedList({
required String id,
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id'
: 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor',
final queryParams = {'first': '100', 'broadcaster_id': id};
if (cursor != null) queryParams['after'] = cursor;
final data = await get<JsonMap>(
'/users/blocks',
queryParameters: queryParams,
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final cursor = decoded['pagination']['cursor'];
final blockedList = decoded['data'] as List;
final paginationCursor = data['pagination']['cursor'];
final blockedList = data['data'] as JsonList;
if (blockedList.isNotEmpty) {
final result =
blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList();
final result = blockedList
.map((e) => UserBlockedTwitch.fromJson(e))
.toList();
if (cursor != null) {
if (paginationCursor != null) {
// Wait a bit (150 milliseconds) before recursively calling.
// This will prevent going over the rate limit to due a massive blocked users list.
//
@ -489,11 +360,7 @@ class TwitchApi {
// Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute.
await Future.delayed(const Duration(milliseconds: 150));
result.addAll(
await getUserBlockedList(
id: id,
cursor: cursor,
headers: headers,
),
await getUserBlockedList(id: id, cursor: paginationCursor),
);
}
@ -502,83 +369,100 @@ class TwitchApi {
debugPrint('User does not have anyone blocked');
return [];
}
} else {
return Future.error('User does not exist');
}
}
// Blocks the user with the given ID and returns true on success or false on failure.
Future<bool> blockUser({
required String userId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
Future<bool> blockUser({required String userId}) async {
try {
await put<dynamic>(
'/users/blocks',
queryParameters: {'target_user_id': userId},
);
final response = await _client.put(url, headers: headers);
if (response.statusCode == 204) {
return true;
} else {
return true; // If no exception, operation succeeded
} on ApiException {
return false;
}
}
// Unblocks the user with the given ID and returns true on success or false on failure.
Future<bool> unblockUser({
required String userId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
Future<bool> unblockUser({required String userId}) async {
try {
await delete<dynamic>(
'/users/blocks',
queryParameters: {'target_user_id': userId},
);
final response = await _client.delete(url, headers: headers);
if (response.statusCode == 204) {
return true;
} else {
return true; // If no exception, operation succeeded
} on ApiException {
return false;
}
}
Future<SharedChatSession?> getSharedChatSession({
required String broadcasterId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/shared_chat/session?broadcaster_id=$broadcasterId',
final data = await get<JsonMap>(
'/shared_chat/session',
queryParameters: {'broadcaster_id': broadcasterId},
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
final sessionData = jsonDecode(response.body)['data'] as List;
final sessionData = data['data'] as JsonList;
if (sessionData.isEmpty) {
return null;
}
return SharedChatSession.fromJson(sessionData.first);
} else {
return Future.error('Failed to get shared chat session info');
}
}
// Unblocks the user with the given ID and returns true on success or false on failure.
Future<List<dynamic>> getRecentMessages({
required String userLogin,
}) async {
final url = Uri.parse(
'https://recent-messages.robotty.de/api/v2/recent-messages/$userLogin',
/// Gets the color used for the user's name in chat.
/// [userId] - The ID of the user whose chat color to get
/// Returns the color as a hex string or empty string if no color is set.
Future<String> getUserChatColor({required String userId}) async {
try {
final data = await get<JsonMap>(
'/chat/color',
queryParameters: {'user_id': userId},
);
final response = await _client.get(url);
final decodedBody = utf8.decode(response.bodyBytes);
if (response.statusCode == 200) {
return jsonDecode(decodedBody)['messages'] as List;
} else {
return Future.error('Failed to get recent messages for $userLogin');
final users = data['data'] as JsonList;
if (users.isNotEmpty) {
final user = users.first as JsonMap;
return user['color'] as String? ?? '';
}
return '';
} on ApiException {
return '';
}
}
/// Updates the color used for the user's name in chat.
/// [userId] - The ID of the user whose chat color to update
/// [color] - The color to use. Can be a named color (blue, blue_violet, etc.) or hex code for Turbo/Prime users
/// Returns true on success or false on failure.
Future<bool> updateUserChatColor({
required String userId,
required String color,
}) async {
try {
await put<dynamic>(
'/chat/color',
queryParameters: {'user_id': userId, 'color': color},
);
return true; // If no exception, operation succeeded
} on ApiException catch (e) {
// Log the specific error for debugging
debugPrint('Failed to update chat color: $e');
return false;
}
}
// Gets recent messages from a third-party service.
Future<JsonList> getRecentMessages({required String userLogin}) async {
// Use custom base URL for third-party service
final data = await get<JsonMap>(
'$_recentMessagesUrl/recent-messages/$userLogin',
);
return data['messages'] as JsonList;
}
}

View file

@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Dio interceptor that automatically adds Twitch authentication headers
/// to requests targeting Twitch API endpoints.
class TwitchAuthInterceptor extends Interceptor {
final AuthStore _authStore;
const TwitchAuthInterceptor(this._authStore);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Check if this is a Twitch API request that needs authentication
if (_shouldAddTwitchHeaders(options.uri)) {
// Add Twitch auth headers automatically
final twitchHeaders = _authStore.headersTwitch;
options.headers.addAll(twitchHeaders);
}
handler.next(options);
}
/// Determines if the request URL is for a Twitch API endpoint that requires authentication
bool _shouldAddTwitchHeaders(Uri uri) {
final url = uri.toString();
// Add headers for Twitch Helix API and OAuth endpoints
return url.startsWith('https://api.twitch.tv/helix') ||
url.startsWith('https://id.twitch.tv/oauth2');
}
}

View file

@ -0,0 +1,91 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog
class UnauthorizedInterceptor extends Interceptor {
final AuthStore _authStore;
UnauthorizedInterceptor(this._authStore);
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Check if this is a 401 Unauthorized error
if (err.response?.statusCode == 401) {
// For token validation requests, let the error propagate so validateToken can handle it
if (err.requestOptions.uri.path.endsWith('/validate')) {
handler.next(err);
return;
}
// For other requests, show login dialog and don't propagate
_showLoginDialog();
return;
}
// For non-401 errors, continue with normal error handling
handler.next(err);
}
void _showLoginDialog() {
// Use the global navigator key to show dialog without needing BuildContext
final context = navigatorKey.currentContext;
if (context == null) return;
// Use a flag to prevent multiple dialogs
if (_isDialogShowing) return;
_isDialogShowing = true;
// Determine if user is logged in but missing scopes vs completely logged out
final isLoggedIn = _authStore.isLoggedIn;
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
final message = isLoggedIn
? 'Your session is missing permissions. Please log in again to continue.'
: 'Your session has expired. Please log in again to continue.';
showDialog(
context: context,
barrierDismissible: false, // User must choose an action
builder: (BuildContext dialogContext) {
return FrostyDialog(
title: title,
message: message,
actions: [
TextButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop(); // Close dialog
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
_isDialogShowing = false;
Navigator.of(dialogContext).pop(); // Close dialog
// Navigate to login WebView
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
child: Text('Log in'),
),
],
);
},
).then((_) {
// Reset flag when dialog is dismissed
_isDialogShowing = false;
});
}
static bool _isDialogShowing = false;
}

View file

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
/// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID');
@ -19,6 +21,9 @@ const zeroWidthEmotes = [
/// Regex for matching strings that contain lower or upper case English characters.
final regexEnglish = RegExp(r'[a-zA-Z]');
/// Regex for matching strings that contain only numeric characters.
final regexNumbersOnly = RegExp(r'^\d+$');
/// Regex for matching URLs and file names in text.
final regexLink = RegExp(
r'(?<![A-Za-z0-9_.-])' // left boundary
@ -42,3 +47,41 @@ const defaultBadgeSize = 18.0;
/// The default emote width and height when none are provided.
const defaultEmoteSize = 28.0;
/// Available named chat colors for Twitch users.
const chatColorNames = [
'blue',
'blue_violet',
'cadet_blue',
'chocolate',
'coral',
'dodger_blue',
'firebrick',
'golden_rod',
'green',
'hot_pink',
'orange_red',
'red',
'sea_green',
'spring_green',
'yellow_green',
];
/// Color values for the named chat colors.
const Map<String, Color> chatColorValues = {
'blue': Color(0xFF0000FF),
'blue_violet': Color(0xFF8A2BE2),
'cadet_blue': Color(0xFF5F9EA0),
'chocolate': Color(0xFFD2691E),
'coral': Color(0xFFFF7F50),
'dodger_blue': Color(0xFF1E90FF),
'firebrick': Color(0xFFB22222),
'golden_rod': Color(0xFFDAA520),
'green': Color(0xFF008000),
'hot_pink': Color(0xFFFF69B4),
'orange_red': Color(0xFFFF4500),
'red': Color(0xFFFF0000),
'sea_green': Color(0xFF2E8B57),
'spring_green': Color(0xFF00FF7F),
'yellow_green': Color(0xFF9ACD32),
};

View file

@ -10,9 +10,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/dio_client.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_auth_interceptor.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart';
import 'package:frosty/cache_manager.dart';
import 'package:frosty/firebase_options.dart';
import 'package:frosty/screens/channel/channel.dart';
@ -20,10 +23,10 @@ import 'package:frosty/screens/home/home.dart';
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:http/http.dart';
import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -34,9 +37,7 @@ void main() async {
CustomCacheManager.removeOrphanedCacheFiles();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Pass all uncaught "fatal" errors from the framework to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
@ -72,27 +73,45 @@ void main() async {
// Create a MobX reaction that will save the settings on disk every time they are changed.
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
/// Initialize API services with a common client.
/// Initialize API services with a common Dio client.
/// This will prevent every request from creating a new client instance.
final client = Client();
final twitchApiService = TwitchApi(client);
final bttvApiService = BTTVApi(client);
final ffzApiService = FFZApi(client);
final sevenTVApiService = SevenTVApi(client);
final dioClient = DioClient.createClient();
// Create API services
final twitchApiService = TwitchApi(dioClient);
final bttvApiService = BTTVApi(dioClient);
final ffzApiService = FFZApi(dioClient);
final sevenTVApiService = SevenTVApi(dioClient);
// Create global assets store (shared cache for global emotes/badges)
final globalAssetsStore = GlobalAssetsStore(
twitchApi: twitchApiService,
bttvApi: bttvApiService,
ffzApi: ffzApiService,
sevenTVApi: sevenTVApiService,
);
// Create and initialize the authentication store
final authStore = AuthStore(twitchApi: twitchApiService);
// Add the auth interceptor to the Dio client after AuthStore creation
dioClient.interceptors.add(TwitchAuthInterceptor(authStore));
// Add the unauthorized interceptor to catch 401 errors
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
await authStore.init();
runApp(
MultiProvider(
providers: [
Provider<AuthStore>(create: (_) => authStore),
Provider<SettingsStore>(create: (_) => settingsStore),
Provider<TwitchApi>(create: (_) => twitchApiService),
Provider<BTTVApi>(create: (_) => bttvApiService),
Provider<FFZApi>(create: (_) => ffzApiService),
Provider<SevenTVApi>(create: (_) => sevenTVApiService),
Provider<AuthStore>.value(value: authStore),
Provider<SettingsStore>.value(value: settingsStore),
Provider<TwitchApi>.value(value: twitchApiService),
Provider<BTTVApi>.value(value: bttvApiService),
Provider<FFZApi>.value(value: ffzApiService),
Provider<SevenTVApi>.value(value: sevenTVApiService),
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
],
child: MyApp(firstRun: firstRun),
),
@ -105,10 +124,7 @@ final navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatefulWidget {
final bool firstRun;
const MyApp({
super.key,
this.firstRun = false,
});
const MyApp({super.key, this.firstRun = false});
@override
State<MyApp> createState() => _MyAppState();
@ -137,8 +153,9 @@ class _MyAppState extends State<MyApp> {
return Observer(
builder: (context) {
final settingsStore = context.read<SettingsStore>();
final themes =
FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor));
final themes = FrostyThemes(
colorSchemeSeed: Color(settingsStore.accentColor),
);
return Provider<FrostyThemes>(
create: (_) => themes,
@ -198,12 +215,8 @@ class _MyAppState extends State<MyApp> {
try {
final twitchApi = context.read<TwitchApi>();
final authStore = context.read<AuthStore>();
final user = await twitchApi.getUser(
userLogin: channelName,
headers: authStore.headersTwitch,
);
final user = await twitchApi.getUser(userLogin: channelName);
final route = MaterialPageRoute(
builder: (context) => VideoChat(
@ -223,15 +236,18 @@ class _MyAppState extends State<MyApp> {
debugPrint('Failed to open link $uri due to error: $e');
if (navigatorKey.currentContext == null) return;
ScaffoldMessenger.of(navigatorKey.currentContext!)
.showSnackBar(failureSnackbar);
ScaffoldMessenger.of(
navigatorKey.currentContext!,
).showSnackBar(failureSnackbar);
}
}
// TODO: Here we can implement handlers for other types of links
else {
// If we get here, it's a link format that we're unable to handle
if (navigatorKey.currentContext == null) return;
ScaffoldMessenger.of(navigatorKey.currentContext!).showSnackBar(failureSnackbar);
ScaffoldMessenger.of(
navigatorKey.currentContext!,
).showSnackBar(failureSnackbar);
}
}

View file

@ -35,12 +35,7 @@ class BadgeInfoFFZ {
final String color;
final BadgeUrlsFFZ urls;
const BadgeInfoFFZ(
this.id,
this.title,
this.color,
this.urls,
);
const BadgeInfoFFZ(this.id, this.title, this.color, this.urls);
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoFFZFromJson(json);
@ -55,11 +50,7 @@ class BadgeUrlsFFZ {
@JsonKey(name: '4')
final String url4x;
const BadgeUrlsFFZ(
this.url1x,
this.url2x,
this.url4x,
);
const BadgeUrlsFFZ(this.url1x, this.url2x, this.url4x);
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeUrlsFFZFromJson(json);
@ -71,11 +62,7 @@ class BadgeInfo7TV {
final List<List<String>> urls;
final List<String> users;
BadgeInfo7TV(
this.tooltip,
this.urls,
this.users,
);
BadgeInfo7TV(this.tooltip, this.urls, this.users);
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfo7TVFromJson(json);
@ -86,10 +73,7 @@ class BadgeInfoBTTV {
final String providerId;
final BadgeDetailsBTTV badge;
BadgeInfoBTTV(
this.providerId,
this.badge,
);
BadgeInfoBTTV(this.providerId, this.badge);
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoBTTVFromJson(json);
@ -100,10 +84,7 @@ class BadgeDetailsBTTV {
final String description;
final String svg;
BadgeDetailsBTTV(
this.description,
this.svg,
);
BadgeDetailsBTTV(this.description, this.svg);
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeDetailsBTTVFromJson(json);

View file

@ -21,13 +21,10 @@ BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
json['title'] as String,
json['color'] as String,
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),
);
);
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) => BadgeUrlsFFZ(
json['1'] as String,
json['2'] as String,
json['4'] as String,
);
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) =>
BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String);
BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
json['tooltip'] as String,
@ -35,7 +32,7 @@ BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
.toList(),
(json['users'] as List<dynamic>).map((e) => e as String).toList(),
);
);
BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
BadgeInfoBTTV(
@ -44,7 +41,4 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
);
BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map<String, dynamic> json) =>
BadgeDetailsBTTV(
json['description'] as String,
json['svg'] as String,
);
BadgeDetailsBTTV(json['description'] as String, json['svg'] as String);

View file

@ -8,11 +8,7 @@ class CategoryTwitch {
final String id;
final String name;
const CategoryTwitch(
this.boxArtUrl,
this.id,
this.name,
);
const CategoryTwitch(this.boxArtUrl, this.id, this.name);
factory CategoryTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoryTwitchFromJson(json);
@ -23,10 +19,7 @@ class CategoriesTwitch {
final List<CategoryTwitch> data;
final Map<String, String>? pagination;
const CategoriesTwitch(
this.data,
this.pagination,
);
const CategoriesTwitch(this.data, this.pagination);
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoriesTwitchFromJson(json);

View file

@ -8,11 +8,19 @@ class Channel {
final String broadcasterId;
final String broadcasterLogin;
final String broadcasterName;
final String broadcasterLanguage;
final String title;
final String gameId;
final String gameName;
const Channel({
required this.broadcasterId,
required this.broadcasterLogin,
required this.broadcasterName,
required this.broadcasterLanguage,
required this.title,
required this.gameId,
required this.gameName,
});
factory Channel.fromJson(Map<String, dynamic> json) =>

View file

@ -10,7 +10,11 @@ Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
broadcasterId: json['broadcaster_id'] as String,
broadcasterLogin: json['broadcaster_login'] as String,
broadcasterName: json['broadcaster_name'] as String,
);
broadcasterLanguage: json['broadcaster_language'] as String,
title: json['title'] as String,
gameId: json['game_id'] as String,
gameName: json['game_name'] as String,
);
ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
broadcasterLogin: json['broadcaster_login'] as String,
@ -18,4 +22,4 @@ ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
id: json['id'] as String,
isLive: json['is_live'] as bool,
startedAt: json['started_at'] as String,
);
);

View file

@ -7,10 +7,7 @@ class ChatUsers {
final int chatterCount;
final Chatters chatters;
ChatUsers(
this.chatterCount,
this.chatters,
);
ChatUsers(this.chatterCount, this.chatters);
factory ChatUsers.fromJson(Map<String, dynamic> json) =>
_$ChatUsersFromJson(json);

View file

@ -9,7 +9,7 @@ part of 'chatters.dart';
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
(json['chatter_count'] as num).toInt(),
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
);
);
Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
(json['broadcaster'] as List<dynamic>).map((e) => e as String).toList(),
@ -19,4 +19,4 @@ Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
(json['admins'] as List<dynamic>).map((e) => e as String).toList(),
(json['global_mods'] as List<dynamic>).map((e) => e as String).toList(),
(json['viewers'] as List<dynamic>).map((e) => e as String).toList(),
);
);

View file

@ -12,12 +12,7 @@ class EmoteTwitch {
final String? emoteType;
final String? ownerId;
const EmoteTwitch(
this.id,
this.name,
this.emoteType,
this.ownerId,
);
const EmoteTwitch(this.id, this.name, this.emoteType, this.ownerId);
factory EmoteTwitch.fromJson(Map<String, dynamic> json) =>
_$EmoteTwitchFromJson(json);
@ -29,10 +24,7 @@ class EmoteBTTV {
final String id;
final String code;
const EmoteBTTV(
this.id,
this.code,
);
const EmoteBTTV(this.id, this.code);
factory EmoteBTTV.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVFromJson(json);
@ -43,10 +35,7 @@ class EmoteBTTVChannel {
final List<EmoteBTTV> channelEmotes;
final List<EmoteBTTV> sharedEmotes;
const EmoteBTTVChannel(
this.channelEmotes,
this.sharedEmotes,
);
const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes);
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVChannelFromJson(json);
@ -59,11 +48,7 @@ class RoomFFZ {
final ImagesFFZ? vipBadge;
final ImagesFFZ? modUrls;
const RoomFFZ(
this.set,
this.vipBadge,
this.modUrls,
);
const RoomFFZ(this.set, this.vipBadge, this.modUrls);
factory RoomFFZ.fromJson(Map<String, dynamic> json) =>
_$RoomFFZFromJson(json);
@ -78,11 +63,7 @@ class ImagesFFZ {
@JsonKey(name: '4')
final String? url4x;
const ImagesFFZ(
this.url1x,
this.url2x,
this.url4x,
);
const ImagesFFZ(this.url1x, this.url2x, this.url4x);
factory ImagesFFZ.fromJson(Map<String, dynamic> json) =>
_$ImagesFFZFromJson(json);
@ -93,10 +74,7 @@ class OwnerFFZ {
final String displayName;
final String name;
const OwnerFFZ({
required this.displayName,
required this.name,
});
const OwnerFFZ({required this.displayName, required this.name});
factory OwnerFFZ.fromJson(Map<String, dynamic> json) =>
_$OwnerFFZFromJson(json);
@ -130,11 +108,7 @@ class Emote7TV {
final String name;
final Emote7TVData data;
const Emote7TV(
this.id,
this.name,
this.data,
);
const Emote7TV(this.id, this.name, this.data);
factory Emote7TV.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFromJson(json);
@ -145,10 +119,7 @@ class Owner7TV {
final String username;
final String displayName;
const Owner7TV({
required this.username,
required this.displayName,
});
const Owner7TV({required this.username, required this.displayName});
factory Owner7TV.fromJson(Map<String, dynamic> json) =>
_$Owner7TVFromJson(json);
@ -162,13 +133,7 @@ class Emote7TVData {
final Owner7TV? owner;
final Emote7TVHost host;
const Emote7TVData(
this.id,
this.name,
this.flags,
this.owner,
this.host,
);
const Emote7TVData(this.id, this.name, this.flags, this.owner, this.host);
factory Emote7TVData.fromJson(Map<String, dynamic> json) =>
_$Emote7TVDataFromJson(json);
@ -179,10 +144,7 @@ class Emote7TVHost {
final String url;
final List<Emote7TVFile> files;
Emote7TVHost(
this.url,
this.files,
);
Emote7TVHost(this.url, this.files);
factory Emote7TVHost.fromJson(Map<String, dynamic> json) =>
_$Emote7TVHostFromJson(json);
@ -195,12 +157,7 @@ class Emote7TVFile {
final int height;
final String format;
Emote7TVFile(
this.name,
this.width,
this.height,
this.format,
);
Emote7TVFile(this.name, this.width, this.height, this.format);
factory Emote7TVFile.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFileFromJson(json);
@ -254,7 +211,8 @@ class Emote {
zeroWidth: false,
width: emote.width,
height: emote.height,
url: emote.animated?.url4x ??
url:
emote.animated?.url4x ??
emote.animated?.url2x ??
emote.animated?.url1x ??
emote.urls.url4x ??

View file

@ -11,12 +11,10 @@ EmoteTwitch _$EmoteTwitchFromJson(Map<String, dynamic> json) => EmoteTwitch(
json['name'] as String,
json['emote_type'] as String?,
json['owner_id'] as String?,
);
);
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) => EmoteBTTV(
json['id'] as String,
json['code'] as String,
);
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) =>
EmoteBTTV(json['id'] as String, json['code'] as String);
EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
EmoteBTTVChannel(
@ -36,18 +34,15 @@ RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
json['mod_urls'] == null
? null
: ImagesFFZ.fromJson(json['mod_urls'] as Map<String, dynamic>),
);
);
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) => ImagesFFZ(
json['1'] as String,
json['2'] as String?,
json['4'] as String?,
);
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) =>
ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?);
OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
displayName: json['display_name'] as String,
name: json['name'] as String,
);
);
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['name'] as String,
@ -58,18 +53,18 @@ EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
json['animated'] == null
? null
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
);
);
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
json['id'] as String,
json['name'] as String,
Emote7TVData.fromJson(json['data'] as Map<String, dynamic>),
);
);
Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
username: json['username'] as String,
displayName: json['display_name'] as String,
);
);
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
json['id'] as String,
@ -79,21 +74,21 @@ Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
? null
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
Emote7TVHost.fromJson(json['host'] as Map<String, dynamic>),
);
);
Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
json['url'] as String,
(json['files'] as List<dynamic>)
.map((e) => Emote7TVFile.fromJson(e as Map<String, dynamic>))
.toList(),
);
);
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
json['name'] as String,
(json['width'] as num).toInt(),
(json['height'] as num).toInt(),
json['format'] as String,
);
);
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
name: json['name'] as String,
@ -106,7 +101,7 @@ Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
ownerDisplayName: json['ownerDisplayName'] as String?,
ownerUsername: json['ownerUsername'] as String?,
ownerId: json['ownerId'] as String?,
);
);
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'name': instance.name,
@ -119,7 +114,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
'ownerDisplayName': instance.ownerDisplayName,
'ownerUsername': instance.ownerUsername,
'ownerId': instance.ownerId,
};
};
const _$EmoteTypeEnumMap = {
EmoteType.twitchBits: 'twitchBits',

View file

@ -10,11 +10,7 @@ class SevenTVEvent {
final int? t;
final SevenTVEventData d;
const SevenTVEvent({
required this.op,
this.t,
required this.d,
});
const SevenTVEvent({required this.op, this.t, required this.d});
factory SevenTVEvent.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventFromJson(json);
@ -29,11 +25,7 @@ class SevenTVEventData {
@JsonKey(includeToJson: false)
final SevenTVEventEmoteSetBody? body;
const SevenTVEventData({
required this.type,
this.condition,
this.body,
});
const SevenTVEventData({required this.type, this.condition, this.body});
factory SevenTVEventData.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventDataFromJson(json);
@ -46,10 +38,7 @@ class SevenTVEventUpdatedEmote {
final Emote7TV? value;
final Emote7TV? oldValue;
const SevenTVEventUpdatedEmote({
this.value,
this.oldValue,
});
const SevenTVEventUpdatedEmote({this.value, this.oldValue});
factory SevenTVEventUpdatedEmote.fromJson(Map<String, dynamic> json) =>
_$SevenTVEventUpdatedEmoteFromJson(json);

View file

@ -10,23 +10,10 @@ SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
op: (json['op'] as num).toInt(),
t: (json['t'] as num?)?.toInt(),
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
);
);
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) {
final val = <String, dynamic>{
'op': instance.op,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('t', instance.t);
val['d'] = instance.d;
return val;
}
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) =>
<String, dynamic>{'op': instance.op, 't': ?instance.t, 'd': instance.d};
SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
SevenTVEventData(
@ -37,36 +24,32 @@ SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
body: json['body'] == null
? null
: SevenTVEventEmoteSetBody.fromJson(
json['body'] as Map<String, dynamic>),
json['body'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SevenTVEventDataToJson(SevenTVEventData instance) =>
<String, dynamic>{
'type': instance.type,
'condition': instance.condition,
};
<String, dynamic>{'type': instance.type, 'condition': instance.condition};
SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson(
Map<String, dynamic> json) =>
SevenTVEventUpdatedEmote(
Map<String, dynamic> json,
) => SevenTVEventUpdatedEmote(
value: json['value'] == null
? null
: Emote7TV.fromJson(json['value'] as Map<String, dynamic>),
oldValue: json['old_value'] == null
? null
: Emote7TV.fromJson(json['old_value'] as Map<String, dynamic>),
);
);
SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson(
Map<String, dynamic> json) =>
SevenTVEventEmoteSetBody(
Map<String, dynamic> json,
) => SevenTVEventEmoteSetBody(
actor: Owner7TV.fromJson(json['actor'] as Map<String, dynamic>),
pushed: (json['pushed'] as List<dynamic>?)
?.map((e) =>
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
.toList(),
pulled: (json['pulled'] as List<dynamic>?)
?.map((e) =>
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
.toList(),
);
);

View file

@ -0,0 +1,39 @@
import 'package:json_annotation/json_annotation.dart';
part 'followed_channel.g.dart';
// Object for followed Twitch channels.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class FollowedChannel {
final String broadcasterId;
final String broadcasterLogin;
final String broadcasterName;
final DateTime followedAt;
const FollowedChannel({
required this.broadcasterId,
required this.broadcasterLogin,
required this.broadcasterName,
required this.followedAt,
});
factory FollowedChannel.fromJson(Map<String, dynamic> json) =>
_$FollowedChannelFromJson(json);
}
// Response object for followed channels API call.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class FollowedChannels {
final List<FollowedChannel> data;
final Map<String, String> pagination;
final int total;
const FollowedChannels({
required this.data,
required this.pagination,
required this.total,
});
factory FollowedChannels.fromJson(Map<String, dynamic> json) =>
_$FollowedChannelsFromJson(json);
}

View file

@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'followed_channel.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FollowedChannel _$FollowedChannelFromJson(Map<String, dynamic> json) =>
FollowedChannel(
broadcasterId: json['broadcaster_id'] as String,
broadcasterLogin: json['broadcaster_login'] as String,
broadcasterName: json['broadcaster_name'] as String,
followedAt: DateTime.parse(json['followed_at'] as String),
);
FollowedChannels _$FollowedChannelsFromJson(Map<String, dynamic> json) =>
FollowedChannels(
data: (json['data'] as List<dynamic>)
.map((e) => FollowedChannel.fromJson(e as Map<String, dynamic>))
.toList(),
pagination: Map<String, String>.from(json['pagination'] as Map),
total: (json['total'] as num).toInt(),
);

View file

@ -11,12 +11,23 @@ import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:frosty/utils.dart' as utils;
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
// Constants for IRC message rendering
const String _ircActionPrefix = '\x01';
const String _ircActionSuffix = '\x01';
const int _ircActionPrefixLength = 8;
const String _invalidUnicodeChar = '\u{E0000}';
const String _twitchEmoteBaseUrl = 'https://static-cdn.jtvnw.net/emoticons/v2';
const String _twitchIrcServer = 'tmi.twitch.tv';
const String _emoteModeSuffix = '/default/dark/3.0';
const double _emojiSizeOffset = 5.0;
/// The object representation of a Twitch IRC message.
class IRCMessage {
final String raw;
@ -28,6 +39,8 @@ class IRCMessage {
List<String>? split;
bool? action;
bool? mention;
final VoidCallback? actionCallback;
final String? actionLabel;
IRCMessage({
required this.raw,
@ -39,6 +52,8 @@ class IRCMessage {
this.split,
this.action,
this.mention,
this.actionCallback,
this.actionLabel,
});
/// Applies the given CLEARCHAT message to the provided messages and buffer.
@ -112,44 +127,25 @@ class IRCMessage {
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan(
BuildContext context, {
/// Adds timestamp to the span if enabled
void _addTimestamp(
List<InlineSpan> span,
TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool launchExternal,
void Function()? onTapName,
void Function(String)? onTapPingedUser,
bool showMessage = true,
bool useReadableColors = false,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
}) {
final isLightTheme = Theme.of(context).brightness == Brightness.light;
TimestampType timestamp,
) {
if (timestamp == TimestampType.disabled) return;
final emoteToObject = assetsStore.emoteToObject;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
if (timestamp != TimestampType.disabled) {
final time = tags['tmi-sent-ts'] ??
DateTime.now().millisecondsSinceEpoch.toString();
final time =
tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
if (timestamp == TimestampType.twentyFour) {
final timeText = timestamp == TimestampType.twentyFour
? '${DateFormat.Hm().format(parsedTime)} '
: '${DateFormat('h:mm').format(parsedTime)} ';
span.add(
TextSpan(
text: '${DateFormat.Hm().format(parsedTime)} ',
text: timeText,
style: style?.copyWith(
color: style.color?.withValues(alpha: 0.5),
fontFeatures: [FontFeature.tabularFigures()],
@ -158,19 +154,14 @@ class IRCMessage {
);
}
if (timestamp == TimestampType.twelve) {
span.add(
TextSpan(
text: '${DateFormat('h:mm').format(parsedTime)} ',
style: style?.copyWith(
color: style.color?.withValues(alpha: 0.5),
fontFeatures: [FontFeature.tabularFigures()],
),
),
);
}
}
/// Adds historical and channel source badges to the span
void _addHistoricalAndChannelBadges(
BuildContext context,
List<InlineSpan> span,
double badgeSize,
Map<String, UserTwitch>? channelIdToUserTwitch,
String? currentChannelId,
) {
final isHistorical = tags['historical'] == '1';
if (isHistorical) {
span.add(
@ -180,7 +171,13 @@ class IRCMessage {
message: 'Historical message',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Icon(Icons.history_rounded, size: badgeSize),
child: Icon(
Icons.history_rounded,
size: badgeSize,
color:
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
Colors.grey.withValues(alpha: 0.5),
),
),
),
);
@ -188,17 +185,19 @@ class IRCMessage {
}
final sourceChannelId = tags['source-room-id'] ?? tags['room-id'];
final sourceChannelUser = channelIdToUserTwitch != null
? channelIdToUserTwitch[sourceChannelId]
: null;
final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId];
if (sourceChannelUser != null) {
final isCurrentChannel = sourceChannelId == currentChannelId;
span.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
preferBelow: false,
message: 'Sent from ${sourceChannelUser.displayName}',
message: isCurrentChannel
? 'Sent from ${sourceChannelUser.displayName} (current channel)'
: 'Sent from ${sourceChannelUser.displayName}',
child: CachedNetworkImage(
cacheManager: CustomCacheManager.instance,
imageUrl: sourceChannelUser.profileImageUrl,
@ -207,8 +206,22 @@ class IRCMessage {
height: badgeSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
image:
DecorationImage(image: imageProvider, fit: BoxFit.cover),
border: isCurrentChannel
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 1.5,
)
: null,
),
child: Container(
margin: isCurrentChannel ? const EdgeInsets.all(1.5) : null,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
),
),
placeholder: (context, url) => Container(
@ -216,7 +229,12 @@ class IRCMessage {
height: badgeSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey,
border: isCurrentChannel
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 1.5,
)
: null,
),
),
),
@ -225,32 +243,47 @@ class IRCMessage {
);
span.add(const TextSpan(text: ' '));
}
}
// Indicator to skip adding the bot badges later when adding the rest of FFZ badges.
/// Adds all user badges (Twitch, FFZ, BTTV, 7TV) to the span
bool _addUserBadges(
BuildContext context,
List<InlineSpan> span,
ChatAssetsStore assetsStore,
double badgeSize,
bool launchExternal,
bool isHistorical,
) {
var skipBot = false;
final twitchBadgeToObject = assetsStore.twitchBadgesToObject;
final ffzUserToBadges = assetsStore.userToFFZBadges;
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final ffzUserBadges = ffzUserToBadges[tags['user-id']];
final twitchBadges = tags['badges']?.split(',');
// Pasrse and add the Twitch badges to the span if they exist.
// Add Twitch badges
if (twitchBadges != null) {
for (final badge in twitchBadges) {
final badgeInfo = twitchBadgeToObject[badge];
if (badgeInfo != null) {
var badgeUrl = badgeInfo.url;
// Add custom FFZ mod badge if it exists.
// Handle custom FFZ mod badge
if (badgeInfo.name == 'Moderator' &&
(ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
// Check if mod is bot.
final botBadge = ffzUserBadges
?.firstWhereOrNull((element) => element.name == 'Bot');
final botBadge = ffzUserBadges?.firstWhereOrNull(
(element) => element.name == 'Bot',
);
// Check if user has bot badge or room has custom FFZ mod badges
if (botBadge != null) {
badgeUrl = botBadge.url;
skipBot = true;
} else if (ffzRoomInfo?.modUrls != null) {
badgeUrl = ffzRoomInfo!.modUrls?.url4x ??
badgeUrl =
ffzRoomInfo!.modUrls?.url4x ??
ffzRoomInfo.modUrls?.url2x ??
ffzRoomInfo.modUrls!.url1x;
}
@ -274,9 +307,10 @@ class IRCMessage {
continue;
}
// Add custom FFZ vip badge if it exists
// Handle custom FFZ VIP badge
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
badgeUrl = ffzRoomInfo!.vipBadge?.url4x ??
badgeUrl =
ffzRoomInfo!.vipBadge?.url4x ??
ffzRoomInfo.vipBadge?.url2x ??
ffzRoomInfo.vipBadge!.url1x;
}
@ -300,7 +334,7 @@ class IRCMessage {
}
}
// Add FFZ badges to span
// Add FFZ badges
if (ffzUserBadges != null) {
for (final badge in ffzUserBadges) {
final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF')));
@ -335,7 +369,7 @@ class IRCMessage {
}
}
// Add BTTV badges to span
// Add BTTV badges
final userBTTVBadge = bttvUserToBadge[tags['user-id']];
if (userBTTVBadge != null) {
span.add(
@ -350,7 +384,7 @@ class IRCMessage {
span.add(const TextSpan(text: ' '));
}
// Add 7TV badges to end of badges span
// Add 7TV badges
final user7TVBadges = sevenTVUserToBadges[tags['user-id']];
if (user7TVBadges != null) {
for (final badge in user7TVBadges) {
@ -366,117 +400,196 @@ class IRCMessage {
}
}
return skipBot;
}
/// Adds the username to the span with proper color and styling
void _addUsername(
List<InlineSpan> span,
BuildContext context,
void Function()? onTapName,
) {
var color = Color(
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
);
if (useReadableColors) {
final hsl = HSLColor.fromColor(color);
if (isLightTheme == true) {
if (hsl.lightness >= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5))
.toColor();
}
} else {
if (hsl.lightness <= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5))
.toColor();
}
}
}
// Adjust color for theme contrast using the common utility
color = utils.adjustChatNameColor(context, color);
// Add the display name (username) to the span and apply the onLongPressName callback.
final displayName = tags['display-name']!;
span.add(
TextSpan(
text: user != null ? getReadableName(displayName, user!) : displayName,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
text: user != null
? utils.getReadableName(displayName, user!)
: displayName,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
recognizer: TapGestureRecognizer()..onTap = onTapName,
),
);
// Add the colon separator between the username and their message to the span.
// Add the colon separator between the username and their message
if (action == false) span.add(const TextSpan(text: ':'));
}
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle =
action == true ? const TextStyle(fontStyle: FontStyle.italic) : style;
/// Adds message content with emotes, emojis and text processing
void _addMessageContent(
BuildContext context,
List<InlineSpan> span,
Map<String, Emote> emoteToObject,
double emoteSize,
double emoteScale,
bool showMessage,
bool launchExternal,
TextStyle? textStyle,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage,
) {
if (!showMessage) {
span.add(const TextSpan(text: ' <message deleted>'));
} else {
// Check if the message is a reply. If it is, remove the reply username (first word) from the message.
span.add(
TextSpan(
text: ' <message deleted>',
style: onTapDeletedMessage != null
? textStyle?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
)
: null,
recognizer: onTapDeletedMessage != null
? (TapGestureRecognizer()..onTap = onTapDeletedMessage)
: null,
),
);
return;
}
// Check if the message is a reply. If it is, remove the reply username from the message.
final words = tags.containsKey('reply-parent-display-name')
? split?.sublist(1)
: split;
// Add the message and any emotes to the span.
if (words != null) {
// Keep a local span which will be reversed and added to the final span.
final localSpan = <InlineSpan>[];
// Iterate through the words in reverse-order.
// Index starts at the last word in the message.
var index = words.length - 1;
// Terminate after the first word in the message reached.
while (index != -1) {
// Check if current word is a valid emote.
final word = words[index];
final emote = emoteToObject[word] ?? localEmotes?[word];
if (emote != null) {
// If the emote is zero-width and not the first word, process the stack.
if (emote.zeroWidth && index != 0) {
index = _processZeroWidthEmoteStack(
context,
localSpan,
words,
index,
emote,
emoteToObject,
emoteSize,
emoteScale,
textStyle,
launchExternal,
);
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
),
);
localSpan.add(const TextSpan(text: ' '));
index--;
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(
fontSize: emoteSize - _emojiSizeOffset,
),
),
);
} else {
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
localSpan.add(const TextSpan(text: ' '));
index--;
}
}
// Add the local span reversed to the final span.
span.addAll(localSpan.reversed);
}
}
/// Handles zero-width emote stacking logic and returns updated index
int _processZeroWidthEmoteStack(
BuildContext context,
List<InlineSpan> localSpan,
List<String> words,
int startIndex,
Emote startEmote,
Map<String, Emote> emoteToObject,
double emoteSize,
double emoteScale,
TextStyle? textStyle,
bool launchExternal,
) {
final emoteStack = <Emote>[];
var index = startIndex;
// Handle stacking consecutive zero-width emotes.
Emote? nextEmote = emote;
Emote? nextEmote = startEmote;
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
emoteStack.add(nextEmote);
index--;
nextEmote =
emoteToObject[words[index]] ?? localEmotes?[words[index]];
nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]];
}
// If there's one more emote thats NOT zero-width, add it to the base of the stack.
// If there's one more emote that's NOT zero-width, add it to the base of the stack.
if (nextEmote != null) emoteStack.add(nextEmote);
// Check if the next word is an emoji to be added to the stack.
final nextWordIsEmoji = regexEmoji.hasMatch(words[index]);
// Create the stack of emotes with the base emoji if there is one.
// Will be used as the children of the Stack widget.
final children = [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: emoteSize - 5),
style: textStyle?.copyWith(fontSize: emoteSize - _emojiSizeOffset),
),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null
? emote.width! * emoteScale
: emoteSize,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
useFade: false,
),
),
];
// Create the message for the tooltip
final message = emoteStack.reversed.map(
(emote) => emote.name,
);
final message = emoteStack.reversed.map((emote) => emote.name);
final emoji = words[index];
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -487,15 +600,10 @@ class IRCMessage {
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(
emoji,
style: textStyle?.copyWith(fontSize: 40),
),
Text(emoji, style: textStyle?.copyWith(fontSize: 40)),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
width: 56,
),
(emote) =>
FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
),
],
),
@ -516,7 +624,7 @@ class IRCMessage {
),
);
// If the next word is neither an emote or emoji, add it as a text span.
// If the next word is neither an emote nor emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' '));
localSpan.add(
@ -524,51 +632,77 @@ class IRCMessage {
text: words[index],
style: textStyle,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
),
);
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
);
} else {
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
);
}
}
// Add space after the zero-width emote stack (like all other elements)
localSpan.add(const TextSpan(text: ' '));
index--;
return index - 1;
}
// Add the the local span reversed to the final span.
span.addAll(localSpan.reversed);
}
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan(
BuildContext context, {
TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
required double emoteScale,
required bool launchExternal,
void Function()? onTapName,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage,
bool showMessage = true,
Map<String, UserTwitch>? channelIdToUserTwitch,
TimestampType timestamp = TimestampType.disabled,
String? currentChannelId,
}) {
final emoteToObject = assetsStore.emoteToObject;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
_addTimestamp(span, style, timestamp);
_addHistoricalAndChannelBadges(
context,
span,
badgeSize,
channelIdToUserTwitch,
currentChannelId,
);
final isHistorical = tags['historical'] == '1';
_addUserBadges(
context,
span,
assetsStore,
badgeSize,
launchExternal,
isHistorical,
);
_addUsername(span, context, onTapName);
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle = action == true
? const TextStyle(fontStyle: FontStyle.italic)
: style;
_addMessageContent(
context,
span,
emoteToObject,
emoteSize,
emoteScale,
showMessage,
launchExternal,
textStyle,
onTapPingedUser,
onTapDeletedMessage,
);
return span;
}
@ -579,10 +713,7 @@ class IRCMessage {
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(
emoji,
style: style,
),
child: Text(emoji, style: style),
);
}
@ -603,11 +734,7 @@ class IRCMessage {
),
);
} else if (isSvg == true) {
return SvgPicture.network(
badge.url,
height: size,
width: size,
);
return SvgPicture.network(badge.url, height: size, width: size);
} else {
return FrostyCachedNetworkImage(
imageUrl: badge.url,
@ -724,10 +851,7 @@ class IRCMessage {
}) {
_showAssetDetailsBottomSheet(
context,
leading: FrostyCachedNetworkImage(
imageUrl: emote.url,
width: 56,
),
leading: FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
url: emote.url,
title: emote.realName != null
? '${emote.name} (${emote.realName})'
@ -738,7 +862,7 @@ class IRCMessage {
Text(emote.type.toString()),
if (emote.ownerDisplayName != null && emote.ownerUsername != null)
Text(
'by ${getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
'by ${utils.getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}',
),
],
),
@ -755,7 +879,7 @@ class IRCMessage {
required bool launchExternal,
bool showCopyName = true,
}) {
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => ListView(
shrinkWrap: true,
@ -771,12 +895,11 @@ class IRCMessage {
),
title: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: subtitle,
),
const Divider(indent: 16, endIndent: 16),
if (showCopyName)
ListTile(
leading: const Icon(Icons.copy_rounded),
@ -830,8 +953,9 @@ class IRCMessage {
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags =
whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
final tags = whole
.substring(1, tagAndIrcMessageDivider)
.replaceAll('\\s', ' ');
// Next, parse and map the tags.
final mappedTags = <String, String>{};
@ -858,7 +982,7 @@ class IRCMessage {
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user = splitMessage[0] == 'tmi.twitch.tv'
final String? user = splitMessage[0] == _twitchIrcServer
? mappedTags['login']
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
@ -886,8 +1010,10 @@ class IRCMessage {
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId =
emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
final emoteId = emoteIdAndPosition.substring(
0,
indexBetweenIdAndPositions,
);
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
@ -913,8 +1039,7 @@ class IRCMessage {
localEmotes[emoteWord] = Emote(
name: emoteWord,
zeroWidth: false,
url:
'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0',
url: '$_twitchEmoteBaseUrl/$emoteId$_emoteModeSuffix',
type: EmoteType.twitchSub,
);
}
@ -925,9 +1050,10 @@ class IRCMessage {
List<String>? split;
if (message != null) {
// Check if IRC actions like "/me" were called.
if (message.startsWith('\x01') && message.endsWith('\x01')) {
if (message.startsWith(_ircActionPrefix) &&
message.endsWith(_ircActionSuffix)) {
action = true;
message = message.substring(8, message.length - 1);
message = message.substring(_ircActionPrefixLength, message.length - 1);
}
// Check if the message mentions the logged-in user
@ -941,7 +1067,7 @@ class IRCMessage {
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
.map((word) => word.replaceAll('\u{E0000}', '').trim())
.map((word) => word.replaceAll(_invalidUnicodeChar, '').trim())
.where((element) => element != '')
.join(' ');
@ -1019,11 +1145,17 @@ class IRCMessage {
);
}
factory IRCMessage.createNotice({required String message}) => IRCMessage(
factory IRCMessage.createNotice({
required String message,
VoidCallback? actionCallback,
String? actionLabel,
}) => IRCMessage(
raw: '',
tags: {},
command: Command.notice,
message: message,
actionCallback: actionCallback,
actionLabel: actionLabel,
);
}

View file

@ -17,6 +17,5 @@ SharedChatSession _$SharedChatSessionFromJson(Map<String, dynamic> json) =>
updatedAt: json['updated_at'] as String,
);
Participant _$ParticipantFromJson(Map<String, dynamic> json) => Participant(
broadcasterId: json['broadcaster_id'] as String,
);
Participant _$ParticipantFromJson(Map<String, dynamic> json) =>
Participant(broadcasterId: json['broadcaster_id'] as String);

View file

@ -36,10 +36,7 @@ class StreamsTwitch {
final List<StreamTwitch> data;
final Map<String, String> pagination;
const StreamsTwitch(
this.data,
this.pagination,
);
const StreamsTwitch(this.data, this.pagination);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamsTwitchFromJson(json);

View file

@ -16,7 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
(json['viewer_count'] as num).toInt(),
json['started_at'] as String,
json['thumbnail_url'] as String,
);
);
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
StreamsTwitch(

View file

@ -10,12 +10,7 @@ class UserTwitch {
final String displayName;
final String profileImageUrl;
const UserTwitch(
this.id,
this.login,
this.displayName,
this.profileImageUrl,
);
const UserTwitch(this.id, this.login, this.displayName, this.profileImageUrl);
factory UserTwitch.fromJson(Map<String, dynamic> json) =>
_$UserTwitchFromJson(json);
@ -27,11 +22,7 @@ class UserBlockedTwitch {
final String userLogin;
final String displayName;
const UserBlockedTwitch(
this.userId,
this.userLogin,
this.displayName,
);
const UserBlockedTwitch(this.userId, this.userLogin, this.displayName);
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) =>
_$UserBlockedTwitchFromJson(json);

View file

@ -11,7 +11,7 @@ UserTwitch _$UserTwitchFromJson(Map<String, dynamic> json) => UserTwitch(
json['login'] as String,
json['display_name'] as String,
json['profile_image_url'] as String,
);
);
UserBlockedTwitch _$UserBlockedTwitchFromJson(Map<String, dynamic> json) =>
UserBlockedTwitch(

View file

@ -3,24 +3,19 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/notification.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/draggable_divider.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
@ -42,59 +37,179 @@ class VideoChat extends StatefulWidget {
State<VideoChat> createState() => _VideoChatState();
}
class _VideoChatState extends State<VideoChat> {
class _VideoChatState extends State<VideoChat>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final _videoKey = GlobalKey();
final _chatKey = GlobalKey();
late final ChatStore _chatStore = ChatStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
channelId: widget.userId,
displayName: widget.userName,
auth: context.read<AuthStore>(),
settings: context.read<SettingsStore>(),
chatDetailsStore: ChatDetailsStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
),
assetsStore: ChatAssetsStore(
twitchApi: context.read<TwitchApi>(),
ffzApi: context.read<FFZApi>(),
bttvApi: context.read<BTTVApi>(),
sevenTVApi: context.read<SevenTVApi>(),
),
// PiP drag state - essential only
double _pipDragDistance = 0;
bool _isPipDragging = false;
bool _isInPipTriggerZone =
false; // Track when in trigger zone for haptic feedback
// Divider drag state for synchronizing animation
bool _isDividerDragging = false;
// Essential constants for good UX balance
static const double _pipTriggerDistance = 80;
static const double _pipMaxDragDistance = 150;
// Animation controller for smooth spring-back
late AnimationController _animationController;
late Animation<double> _springBackAnimation;
late final ChatTabsStore _chatTabsStore = ChatTabsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
ffzApi: context.ffzApi,
sevenTVApi: context.sevenTVApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
globalAssetsStore: context.globalAssetsStore,
primaryChannelId: widget.userId,
primaryChannelLogin: widget.userLogin,
primaryDisplayName: widget.userName,
);
late final VideoStore _videoStore = VideoStore(
userLogin: widget.userLogin,
twitchApi: context.read<TwitchApi>(),
authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(),
userId: widget.userId,
twitchApi: context.twitchApi,
authStore: context.authStore,
settingsStore: context.settingsStore,
);
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
void initState() {
super.initState();
final settingsStore = _chatStore.settings;
final appBar = FrostyAppBar(
title: Text(
getReadableName(_chatStore.displayName, _chatStore.channelName),
),
// Initialize animation controller for smooth drag interactions
_animationController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
// Spring-back animation with smooth easing
_springBackAnimation =
Tween<double>(begin: 0, end: 0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
)..addListener(() {
setState(() {
_pipDragDistance = _springBackAnimation.value;
});
});
// Register as observer for app lifecycle events
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
_videoStore.handleAppResume();
}
}
void _handlePipDragStart(DragStartDetails details) {
// Disable drag gesture when already in PiP mode or video is not playing
if (_videoStore.isInPipMode || _videoStore.paused) return;
_animationController.stop(); // Stop any ongoing animation
setState(() {
_isPipDragging = true;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
return;
}
setState(() {
_pipDragDistance += details.delta.dy;
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
// Check if we've entered or exited the trigger zone for haptic feedback
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
// Provide haptic feedback when entering the trigger zone
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact(); // Entering trigger zone
}
// Provide subtle haptic feedback when exiting the trigger zone
else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact(); // Exiting trigger zone
}
});
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
return;
}
final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip =
_pipDragDistance >= _pipTriggerDistance ||
velocity > 600; // Simple velocity threshold
if (shouldTriggerPip) {
// Simple haptic feedback on success
HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture();
_resetDragState();
} else {
// Animate back to original position
_animateSpringBack();
}
}
void _handlePipDragCancel() {
if (!_isPipDragging) return;
_animateSpringBack();
}
void _resetDragState() {
setState(() {
_isPipDragging = false;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _animateSpringBack() {
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
.animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_animationController.reset();
_animationController.forward().then((_) {
_resetDragState();
});
}
/// Convenience getter for the currently active chat store.
ChatStore get _chatStore => _chatTabsStore.activeChatStore;
@override
Widget build(BuildContext context) {
final settingsStore = _chatTabsStore.settingsStore;
final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
child: Video(
key: _videoKey,
videoStore: _videoStore,
),
child: Video(key: _videoKey, videoStore: _videoStore),
);
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onDoubleTap: orientation == Orientation.landscape
onDoubleTap: context.isLandscape
? () => settingsStore.fullScreen = !settingsStore.fullScreen
: null,
onTap: () {
@ -102,7 +217,7 @@ class _VideoChatState extends State<VideoChat> {
_chatStore.assetsStore.showEmoteMenu = false;
} else {
if (_chatStore.textFieldFocusNode.hasFocus) {
_chatStore.textFieldFocusNode.unfocus();
_chatStore.unfocusInput();
} else {
_videoStore.handleVideoTap();
}
@ -124,11 +239,8 @@ class _VideoChatState extends State<VideoChat> {
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: Container(
decoration: BoxDecoration(
color: Colors.black
.withValues(alpha: settingsStore.overlayOpacity),
),
child: ColoredBox(
color: Colors.transparent,
child: IgnorePointer(
ignoring: !_videoStore.overlayVisible,
child: videoOverlay,
@ -143,102 +255,110 @@ class _VideoChatState extends State<VideoChat> {
builder: (context) {
if (!_videoStore.settingsStore.showOverlay) return player;
return Stack(
children: [
player,
overlay,
],
);
return Stack(children: [player, overlay]);
},
);
final chat = Observer(
builder: (context) {
final videoBarVisible = _videoStore.streamInfo != null &&
_chatStore.settings.showVideo &&
(_videoStore.paused || _videoStore.overlayVisible);
final bool chatOnly = !settingsStore.showVideo;
return Stack(
children: [
Chat(
ChatTabs(
key: _chatKey,
chatStore: _chatStore,
chatTabsStore: _chatTabsStore,
// In chat-only mode, account for the blurred AppBar height
listPadding: chatOnly
? EdgeInsets.only(top: context.safePaddingTop)
: null,
),
if (orientation == Orientation.portrait)
AnimatedOpacity(
opacity: videoBarVisible ? 1 : 0,
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: !videoBarVisible,
child: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_videoStore.streamInfo != null)
VideoBar(
streamInfo: _videoStore.streamInfo!,
tappableCategory: false,
),
const Divider(),
],
),
),
),
),
AnimatedSwitcher(
Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _chatStore.notification != null
? Align(
alignment: _chatStore.settings.chatNotificationsOnBottom
? Alignment.bottomCenter
: Alignment.topCenter,
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(
top: chatOnly ? context.safePaddingTop : 0,
),
child: FrostyNotification(
message: _chatStore.notification!,
showPasteButton:
_chatStore.notification!.contains('copied'),
onButtonPressed: () async {
// Paste clipboard text into the text controller.
final data =
await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
_chatStore.textController.text = data.text!;
}
_chatStore.updateNotification('');
},
onDismissed: _chatStore.clearNotification,
),
),
)
: null,
),
),
],
);
},
);
final videoChat = Scaffold(
final videoChat = Observer(
builder: (context) {
// Build a blurred AppBar when in chat-only mode (no video)
PreferredSizeWidget? chatOnlyBlurredAppBar;
if (!settingsStore.showVideo) {
final streamInfo = _videoStore.streamInfo;
chatOnlyBlurredAppBar = AppBar(
centerTitle: false,
elevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
context.theme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
),
title: StreamInfoBar(
streamInfo: streamInfo,
offlineChannelInfo: _videoStore.offlineChannelInfo,
displayName: _chatStore.displayName,
isCompact: true,
isOffline: streamInfo == null,
isInSharedChatMode: _chatStore.isInSharedChatMode,
showTextShadows: false,
),
flexibleSpace: BlurredContainer(
gradientDirection: GradientDirection.up,
child: const SizedBox.expand(),
),
);
}
return Scaffold(
extendBody: true,
extendBodyBehindAppBar: true,
appBar: chatOnlyBlurredAppBar,
body: Observer(
builder: (context) {
if (orientation == Orientation.landscape &&
if (context.isLandscape &&
!settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
final landscapeChat = AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width *
_chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen
? context.screenWidth / 2
: context.screenWidth * settingsStore.chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: _chatStore.settings.fullScreenChatOverlayOpacity,
alpha: settingsStore.fullScreenChatOverlayOpacity,
)
: Theme.of(context).scaffoldBackgroundColor,
: context.scaffoldColor,
child: chat,
);
@ -250,12 +370,18 @@ class _VideoChatState extends State<VideoChat> {
colorSchemeSeed: Color(settingsStore.accentColor),
).dark,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
style: context.defaultTextStyle.copyWith(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
shadows: settingsStore.fullScreen && context.isLandscape
? const [
Shadow(blurRadius: 8),
Shadow(blurRadius: 4, offset: Offset(1, 1)),
]
: null,
),
child: landscapeChat,
),
@ -265,29 +391,55 @@ class _VideoChatState extends State<VideoChat> {
return ColoredBox(
color: settingsStore.showVideo
? Colors.black
: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.left)
? false
: true,
right: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.right)
? false
: true,
: context.scaffoldColor,
child: settingsStore.showVideo
? settingsStore.fullScreen
? Stack(
children: [
player,
if (settingsStore.showOverlay)
LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft: settingsStore
.landscapeChatLeftSide,
showHandle:
_videoStore.overlayVisible &&
settingsStore
.fullScreenChatOverlay,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth =
newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children: settingsStore.landscapeChatLeftSide
children:
settingsStore
.landscapeChatLeftSide
? [
overlayChat,
Expanded(child: overlay),
@ -297,25 +449,126 @@ class _VideoChatState extends State<VideoChat> {
overlayChat,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (totalWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (totalWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
],
)
: Row(
children: settingsStore.landscapeChatLeftSide
: SafeArea(
left:
settingsStore.landscapeCutout !=
LandscapeCutoutType.left &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
right:
settingsStore.landscapeCutout !=
LandscapeCutoutType.right &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft:
settingsStore.landscapeChatLeftSide,
showHandle: _videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth = newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
? [
landscapeChat,
const VerticalDivider(),
chatContainer,
Expanded(child: video),
]
: [
Expanded(child: video),
const VerticalDivider(),
landscapeChat,
chatContainer,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
)
: Column(
children: [appBar, Expanded(child: chat)],
),
),
: SafeArea(child: chat),
);
}
@ -324,21 +577,113 @@ class _VideoChatState extends State<VideoChat> {
overlays: SystemUiOverlay.values,
);
return SafeArea(
child: Column(
// Keep chat-only scrolling under the blurred app bar, but
// ensure video does not bleed into the system status bar.
top: settingsStore.showVideo,
bottom: false,
child: Stack(
children: [
if (!settingsStore.showVideo)
appBar
else ...[
AspectRatio(aspectRatio: 16 / 9, child: video),
const Divider(),
Column(
children: [
if (settingsStore.showVideo) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: Container(), // Placeholder for video space
),
],
Expanded(child: chat),
],
),
if (settingsStore.showVideo)
Positioned(
top: 0,
left: 0,
right: 0,
child: AnimatedBuilder(
animation: Listenable.merge([
_animationController,
_springBackAnimation,
]),
builder: (context, child) {
// Calculate current drag distance from either manual drag or animation
final currentDragDistance = _isPipDragging
? _pipDragDistance
: (_springBackAnimation.value);
// Simple scale effect for visual feedback
final scaleFactor =
1.0 -
(currentDragDistance /
_pipMaxDragDistance *
0.1);
return Transform.translate(
offset: Offset(0, currentDragDistance),
child: Transform.scale(
scale: scaleFactor.clamp(0.9, 1.0),
child: Stack(
children: [
GestureDetector(
onPanStart: _handlePipDragStart,
onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel,
child: AspectRatio(
aspectRatio: 16 / 9,
child: video,
),
),
// Simple text overlay that follows the video with smooth opacity animation
if (!_videoStore.isInPipMode)
Positioned.fill(
child: IgnorePointer(
ignoring:
!(_isPipDragging &&
_pipDragDistance > 0),
child: AnimatedOpacity(
opacity:
(_isPipDragging &&
_pipDragDistance > 0)
? 1.0
: 0.0,
duration: const Duration(
milliseconds: 150,
),
curve: Curves.easeOut,
child: Container(
color: Colors.black.withValues(
alpha: 0.4,
),
child: const Center(
child: Text(
'Swipe down to enter picture-in-picture',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
],
),
),
);
},
),
),
],
),
);
},
),
);
},
);
// If on Android, use PiPSwitcher to enable PiP functionality.
if (Platform.isAndroid) {
@ -355,10 +700,15 @@ class _VideoChatState extends State<VideoChat> {
@override
void dispose() {
_chatStore.dispose();
// Remove observer for app lifecycle events
WidgetsBinding.instance.removeObserver(this);
_chatTabsStore.dispose();
_videoStore.dispose();
_animationController.dispose();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
@ -7,12 +5,24 @@ import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/page_view.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
class Chat extends StatelessWidget {
final ChatStore chatStore;
final EdgeInsetsGeometry? listPadding;
const Chat({super.key, required this.chatStore});
/// Callback to add a new chat tab.
/// Passes this to ChatBottomBar for the ChatDetails menu.
final VoidCallback onAddChat;
const Chat({
super.key,
required this.chatStore,
this.listPadding,
required this.onAddChat,
});
@override
Widget build(BuildContext context) {
@ -26,7 +36,7 @@ class Chat extends StatelessWidget {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.textFieldFocusNode.unfocus();
chatStore.unfocusInput();
}
},
child: Stack(
@ -34,26 +44,45 @@ class Chat extends StatelessWidget {
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler:
TextScaler.linear(chatStore.settings.messageScale),
textScaler: chatStore.settings.messageScale.textScaler,
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Scrollbar(
style: context.defaultTextStyle.copyWith(
fontSize: chatStore.settings.fontSize,
),
child: FrostyScrollbar(
controller: chatStore.scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom:
68 +
(chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (context) {
return ListView.builder(
reverse: true,
padding: EdgeInsets.zero,
padding: (listPadding ?? EdgeInsets.zero).add(
EdgeInsets.only(
bottom:
68 +
(chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(
context,
).padding.bottom),
),
),
addAutomaticKeepAlives: false,
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages[
chatStore.renderMessages.length -
ircMessage:
chatStore.renderMessages[chatStore
.renderMessages
.length -
1 -
index],
chatStore: chatStore,
@ -64,8 +93,40 @@ class Chat extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.all(4),
// Prevents accidental chat scrolling when swiping down from the top edge
// to access system UI (Notification Center/Control Center) in landscape mode.
if (context.isLandscape)
Positioned(
top: 0,
left: 0,
right: 0,
height: 24,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (_) {},
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ChatBottomBar(
chatStore: chatStore,
onAddChat: onAddChat,
),
),
AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
68 +
(chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
@ -75,8 +136,9 @@ class Chat extends StatelessWidget {
? null
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon:
const Icon(Icons.arrow_downward_rounded),
icon: const Icon(
Icons.arrow_downward_rounded,
),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
@ -95,37 +157,19 @@ class Chat extends StatelessWidget {
),
),
),
if (chatStore.settings.showBottomBar)
ChatBottomBar(chatStore: chatStore),
PopScope(
canPop: Platform.isIOS,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// If pressing the back button on Android while the emote menu
// is open, close it instead of going back to the streams list.
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else {
Navigator.of(context).pop();
}
},
child: AnimatedContainer(
AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu
? MediaQuery.of(context).size.height /
(MediaQuery.of(context).orientation ==
Orientation.portrait
? 3
: 2)
? context.screenHeight / (context.isPortrait ? 3 : 2)
: 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? Column(
? ClipRect(
child: Column(
children: [
const Divider(),
Expanded(
@ -139,14 +183,13 @@ class Chat extends StatelessWidget {
if (chatStore.settings.showFFZEmotes) 'FFZ',
],
children: [
RecentEmotesPanel(
chatStore: chatStore,
),
RecentEmotesPanel(chatStore: chatStore),
if (chatStore.settings.showTwitchEmotes)
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore.userEmoteSectionToEmotes,
.assetsStore
.userEmoteSectionToEmotes,
),
...[
if (chatStore.settings.show7TVEmotes)
@ -165,11 +208,11 @@ class Chat extends StatelessWidget {
),
),
],
),
)
: null,
),
),
),
],
);
},

View file

@ -1,12 +1,20 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:intl/intl.dart';
@ -15,11 +23,16 @@ class ChatDetails extends StatefulWidget {
final ChatStore chatStore;
final String userLogin;
/// Callback to add a new chat tab.
/// Shows an "Add chat" option in the menu.
final VoidCallback onAddChat;
const ChatDetails({
super.key,
required this.chatDetailsStore,
required this.chatStore,
required this.userLogin,
required this.onAddChat,
});
@override
@ -29,6 +42,9 @@ class ChatDetails extends StatefulWidget {
class _ChatDetailsState extends State<ChatDetails> {
late final _scrollController = ScrollController();
bool _isRefreshingAssets = false;
bool _showRefreshSuccess = false;
String formatDuration(Duration duration) {
if (duration.inMinutes < 60) {
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
@ -43,11 +59,11 @@ class _ChatDetailsState extends State<ChatDetails> {
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m left';
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s left';
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s left';
return '${seconds}s';
}
}
@ -71,7 +87,7 @@ class _ChatDetailsState extends State<ChatDetails> {
Duration(hours: 12),
];
return showModalBottomSheet(
return showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -91,12 +107,26 @@ class _ChatDetailsState extends State<ChatDetails> {
builder: (context) {
return ListTile(
leading: const Icon(Icons.close_rounded),
title: Text(
'Turn off (${formatTimeLeft(widget.chatStore.timeRemaining)})',
style: const TextStyle(
title: Text.rich(
TextSpan(
text: 'Cancel ',
children: [
TextSpan(
text: formatTimeLeft(
widget.chatStore.timeRemaining,
),
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyMedium
?.color
?.withValues(alpha: 0.6),
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
),
onTap: () {
widget.chatStore.cancelSleepTimer();
@ -128,49 +158,207 @@ class _ChatDetailsState extends State<ChatDetails> {
);
}
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: const Text('Clear recent emotes'),
content:
const Text('Are you sure you want to clear your recent emotes?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
widget.chatStore.assetsStore.recentEmotes.clear();
/// Converts a hex color string to a color name if it matches one of our predefined chat colors.
/// Returns null if no match is found.
String? _hexToColorName(String hexColor) {
// Remove # if present
final cleanHex = hexColor.startsWith('#')
? hexColor.substring(1)
: hexColor;
Navigator.pop(context);
},
child: const Text('Yes'),
// Convert to uppercase for comparison
final upperHex = cleanHex.toUpperCase();
// Find matching color name
for (final entry in chatColorValues.entries) {
final colorValue = entry.value.toARGB32().toRadixString(16).toUpperCase();
// Remove alpha channel (first 2 characters) for comparison
if (colorValue.length >= 6) {
final colorHex = colorValue.substring(2);
if (colorHex == upperHex) {
return entry.key;
}
}
}
return null;
}
Future<void> _showChatColorPicker(BuildContext context) async {
final selectedColor = await _getCurrentUserColor();
if (!context.mounted) return;
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _ChatColorPickerModal(
initialColor: selectedColor,
scrollController: _scrollController,
onColorSelected: _handleColorUpdate,
chatStore: widget.chatStore,
),
],
),
);
}
Future<String?> _getCurrentUserColor() async {
try {
final currentColorHex = await widget.chatStore.twitchApi.getUserChatColor(
userId: widget.chatStore.auth.user.details!.id,
);
if (currentColorHex.isNotEmpty) {
return _hexToColorName(currentColorHex);
}
} catch (e) {
// Ignore errors when fetching current color
}
return null;
}
Future<void> _handleColorUpdate(BuildContext context, String color) async {
final success = await widget.chatStore.twitchApi.updateUserChatColor(
userId: widget.chatStore.auth.user.details!.id,
color: color,
);
if (!context.mounted) return;
Navigator.of(context).pop();
final message = success
? 'Chat color updated successfully!'
: 'Failed to update chat color. Please try again.';
_showMessage(context, message);
}
void _showMessage(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
bool _hasActiveModes() {
final roomState = widget.chatDetailsStore.roomState;
return roomState.subMode != '0' ||
roomState.followersOnly != '-1' ||
roomState.emoteOnly != '0' ||
roomState.slowMode != '0' ||
roomState.r9k != '0';
}
Widget _buildRefreshTrailingWidget() {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _isRefreshingAssets
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: _showRefreshSuccess
? Icon(
Icons.check_rounded,
color: Theme.of(context).colorScheme.primary,
key: const ValueKey('success'),
)
: const SizedBox.shrink(),
);
}
Future<void> _handleRefreshAssets() async {
setState(() => _isRefreshingAssets = true);
try {
await widget.chatStore.getAssets();
if (mounted) {
HapticFeedback.lightImpact();
setState(() {
_isRefreshingAssets = false;
_showRefreshSuccess = true;
});
// Hide success after 2 seconds
Timer(const Duration(seconds: 2), () {
if (mounted) {
setState(() => _showRefreshSuccess = false);
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isRefreshingAssets = false);
}
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final hasActiveModes = _hasActiveModes();
final children = [
const SectionHeader(
'Chat modes',
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true,
if (hasActiveModes)
ListTile(
title: SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Observer(
builder: (context) => ChatModes(
roomState: widget.chatDetailsStore.roomState,
),
),
],
),
),
),
Observer(
builder: (context) {
final showVideo = widget.chatStore.settings.showVideo;
final label = showVideo ? 'Chat only' : 'Show video';
return ListTile(
leading: Icon(
showVideo ? Icons.chat_rounded : Icons.tv_rounded,
),
title: Text(label),
onTap: () {
widget.chatStore.settings.showVideo = !showVideo;
Navigator.of(context).pop();
},
);
},
),
ListTile(
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
),
const SectionHeader(
'More',
leading: const Icon(Icons.sync_rounded),
title: const Text('Refresh emotes and badges'),
trailing: _buildRefreshTrailingWidget(),
enabled: !_isRefreshingAssets,
onTap: _isRefreshingAssets ? null : _handleRefreshAssets,
),
ListTile(
leading: const Icon(Icons.people_outline),
leading: const Icon(Icons.wifi_off_rounded),
title: const Text('Reconnect'),
onTap: () {
Navigator.of(context).pop();
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
if (widget.chatStore.auth.isLoggedIn)
ListTile(
leading: const Icon(Icons.palette_rounded),
title: const Text('Username color'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showChatColorPicker(context),
),
ListTile(
leading: const Icon(Icons.people_rounded),
title: const Text('Chatters'),
onTap: () => showModalBottomSheet(
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => GestureDetector(
@ -183,58 +371,48 @@ class _ChatDetailsState extends State<ChatDetails> {
),
),
),
ListTile(
leading: const Icon(Icons.add_comment_rounded),
title: const Text('Add chat'),
trailing: const Icon(Icons.chevron_right),
onTap: widget.onAddChat,
),
Observer(
builder: (context) {
final hasTimer = widget.chatStore.timeRemaining.inSeconds > 0;
final label = hasTimer
? formatTimeLeft(widget.chatStore.timeRemaining)
: 'Sleep timer';
return ListTile(
leading: const Icon(Icons.timer_outlined),
leading: Icon(
hasTimer ? Icons.timer_rounded : Icons.timer_rounded,
color: hasTimer
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
'Sleep timer ${widget.chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(widget.chatStore.timeRemaining)})' : ''}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
label,
style: hasTimer
? TextStyle(
fontFeatures: const [FontFeature.tabularFigures()],
color: Theme.of(context).colorScheme.primary,
)
: null,
),
trailing: hasTimer
? Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.primary,
)
: const Icon(Icons.chevron_right),
onTap: () => _showSleepTimer(context),
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: const Text('Clear recent emotes'),
onTap: () => _showClearDialog(context),
),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Reconnect to chat'),
onTap: () {
widget.chatStore.updateNotification('Reconnecting to chat...');
widget.chatStore.connectToChat();
},
),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Refresh badges and emotes'),
onTap: () async {
await widget.chatStore.getAssets();
widget.chatStore.updateNotification('Badges and emotes refreshed');
},
),
ListTile(
leading: const Icon(Icons.chat_outlined),
title: Observer(
builder: (context) {
return Text(
'${widget.chatStore.settings.showVideo ? 'Enter' : 'Exit'} chat-only mode',
);
},
),
onTap: () => widget.chatStore.settings.showVideo =
!widget.chatStore.settings.showVideo,
),
ListTile(
leading: const Icon(Icons.settings_outlined),
leading: const Icon(Icons.settings_rounded),
title: const Text('Settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -245,10 +423,8 @@ class _ChatDetailsState extends State<ChatDetails> {
),
];
return ListView(
shrinkWrap: true,
primary: false,
children: children,
return ListView(shrinkWrap: true, primary: false, children: children);
},
);
}
@ -258,3 +434,187 @@ class _ChatDetailsState extends State<ChatDetails> {
super.dispose();
}
}
class _ChatColorPickerModal extends StatefulWidget {
const _ChatColorPickerModal({
required this.initialColor,
required this.scrollController,
required this.onColorSelected,
required this.chatStore,
});
final String? initialColor;
final ScrollController scrollController;
final Future<void> Function(BuildContext context, String color)
onColorSelected;
final ChatStore chatStore;
@override
State<_ChatColorPickerModal> createState() => _ChatColorPickerModalState();
}
class _ChatColorPickerModalState extends State<_ChatColorPickerModal> {
String? selectedColor;
@override
void initState() {
super.initState();
selectedColor = widget.initialColor;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildPreview(),
AnimatedScrollBorder(scrollController: widget.scrollController),
_buildColorList(),
_buildBottomBar(),
],
);
}
Widget _buildHeader() {
return const SectionHeader('Username color', isFirst: true);
}
Widget _buildPreview() {
if (selectedColor == null) return const SizedBox.shrink();
// Create a realistic preview using the current user's chat state and badges
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: _buildRealisticChatPreview(),
);
}
Widget _buildRealisticChatPreview() {
final userDetails = widget.chatStore.auth.user.details!;
final userState = widget.chatStore.userState;
// Create a mock IRC message with the user's current state
final mockTags = <String, String>{
'display-name': userDetails.displayName,
'color':
'#${chatColorValues[selectedColor!]!.toARGB32().toRadixString(16).substring(2)}',
'user-id': userDetails.id,
'mod': userState.mod ? '1' : '0',
'subscriber': userState.subscriber ? '1' : '0',
'id': 'preview-message',
'tmi-sent-ts': DateTime.now().millisecondsSinceEpoch.toString(),
};
// Copy any badges from the current user state
if (userState.raw != null) {
final userStateMessage = IRCMessage.fromString(userState.raw!);
if (userStateMessage.tags['badges'] != null) {
mockTags['badges'] = userStateMessage.tags['badges']!;
}
if (userStateMessage.tags['badge-info'] != null) {
mockTags['badge-info'] = userStateMessage.tags['badge-info']!;
}
}
final mockMessage = IRCMessage(
raw: '',
command: Command.privateMessage,
tags: mockTags,
user: userDetails.login,
message: 'How it will look in chat',
split: ['How', 'it', 'will', 'look', 'in', 'chat'],
action: false,
mention: false,
);
return Text.rich(
TextSpan(
children: mockMessage.generateSpan(
context,
assetsStore: widget.chatStore.assetsStore,
emoteScale: widget.chatStore.settings.emoteScale,
badgeScale: widget.chatStore.settings.badgeScale,
launchExternal: false, // Disable launching for preview
style: DefaultTextStyle.of(context).style,
),
),
);
}
Widget _buildColorList() {
return Expanded(
child: FrostyScrollbar(
controller: widget.scrollController,
child: ListView(
padding: EdgeInsets.zero,
controller: widget.scrollController,
children: chatColorNames
.map((colorName) => _buildColorTile(colorName))
.toList(),
),
),
);
}
Widget _buildColorTile(String colorName) {
final isSelected = selectedColor == colorName;
final displayName = colorName
.replaceAll('_', ' ')
.split(' ')
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(' ');
// Apply the same color adjustment for consistent preview
final rawColor = chatColorValues[colorName]!;
final adjustedColor = adjustChatNameColor(context, rawColor);
return ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(color: adjustedColor, shape: BoxShape.circle),
),
title: Text(displayName),
trailing: isSelected
? Icon(Icons.check, color: Theme.of(context).colorScheme.primary)
: null,
onTap: () => setState(() => selectedColor = colorName),
);
}
Widget _buildBottomBar() {
return Column(
children: [
AnimatedScrollBorder(
scrollController: widget.scrollController,
position: ScrollBorderPosition.bottom,
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: selectedColor == null
? null
: () => widget.onColorSelected(context, selectedColor!),
child: const Text('Save'),
),
),
],
),
),
),
],
);
}
}

View file

@ -12,13 +12,16 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
Computed<Iterable<String>>? _$filteredUsersComputed;
@override
Iterable<String> get filteredUsers => (_$filteredUsersComputed ??=
Computed<Iterable<String>>(() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers'))
.value;
Iterable<String> get filteredUsers =>
(_$filteredUsersComputed ??= Computed<Iterable<String>>(
() => super.filteredUsers,
name: 'ChatDetailsStoreBase.filteredUsers',
)).value;
late final _$roomStateAtom =
Atom(name: 'ChatDetailsStoreBase.roomState', context: context);
late final _$roomStateAtom = Atom(
name: 'ChatDetailsStoreBase.roomState',
context: context,
);
@override
ROOMSTATE get roomState {
@ -33,8 +36,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$showJumpButtonAtom =
Atom(name: 'ChatDetailsStoreBase.showJumpButton', context: context);
late final _$showJumpButtonAtom = Atom(
name: 'ChatDetailsStoreBase.showJumpButton',
context: context,
);
@override
bool get showJumpButton {
@ -49,8 +54,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$_filterTextAtom =
Atom(name: 'ChatDetailsStoreBase._filterText', context: context);
late final _$_filterTextAtom = Atom(
name: 'ChatDetailsStoreBase._filterText',
context: context,
);
String get filterText {
_$_filterTextAtom.reportRead();

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/irc.dart';
class ChatModes extends StatelessWidget {
@ -10,81 +11,172 @@ class ChatModes extends StatelessWidget {
@override
Widget build(BuildContext context) {
final disabledColor =
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5);
return Observer(
builder: (context) {
final activeModes = <MapEntry<String, Widget>>[];
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// Collect active modes with their labels for sorting
if (roomState.emoteOnly != '0') {
activeModes.add(
MapEntry(
'Emote only',
_buildModeChip(
context: context,
icon: Icons.emoji_emotions_outlined,
activeIcon: Icons.emoji_emotions_rounded,
label: 'Emote only',
activeLabel: 'Emote only',
isActive: true,
activeColor: const Color(0xFFFFB74D), // Slightly lighter orange
),
),
);
}
if (roomState.followersOnly != '-1') {
activeModes.add(
MapEntry(
'Follower only',
_buildModeChip(
context: context,
icon: Icons.favorite_outline_rounded,
activeIcon: Icons.favorite_rounded,
label: 'Follower only',
activeLabel: 'Follower only',
isActive: true,
activeColor: const Color(0xFFF44336), // Bright red
duration: _getFollowersDuration(),
),
),
);
}
if (roomState.slowMode != '0') {
activeModes.add(
MapEntry(
'Slow mode',
_buildModeChip(
context: context,
icon: Icons.hourglass_empty_rounded,
activeIcon: Icons.hourglass_top_rounded,
label: 'Slow mode',
activeLabel: 'Slow mode',
isActive: true,
activeColor: const Color(0xFF2196F3), // Bright blue
duration: _getSlowModeDuration(),
),
),
);
}
if (roomState.subMode != '0') {
activeModes.add(
MapEntry(
'Sub only',
_buildModeChip(
context: context,
icon: Icons.monetization_on_outlined,
activeIcon: Icons.monetization_on_rounded,
label: 'Sub only',
activeLabel: 'Sub only',
isActive: true,
activeColor: const Color(0xFF4CAF50), // Bright green
),
),
);
}
if (roomState.r9k != '0') {
activeModes.add(
MapEntry(
'Unique mode',
_buildModeChip(
context: context,
icon: Icons.quiz_outlined,
activeIcon: Icons.quiz_rounded,
label: 'Unique mode',
activeLabel: 'Unique mode',
isActive: true,
activeColor: const Color(0xFFAB47BC), // Slightly lighter purple
),
),
);
}
// Sort alphabetically by label
activeModes.sort((a, b) => a.key.compareTo(b.key));
final activeChips = activeModes.map((entry) => entry.value).toList();
return Wrap(spacing: 8, runSpacing: -4, children: activeChips);
},
);
}
String? _getFollowersDuration() {
if (roomState.followersOnly == '0') {
return null; // No duration for immediate followers
}
final minutes = int.parse(roomState.followersOnly);
if (minutes >= 60) {
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
if (remainingMinutes == 0) {
return '${hours}h';
} else {
return '${hours}h ${remainingMinutes}m';
}
}
return '${minutes}m';
}
String _getSlowModeDuration() {
final seconds = int.parse(roomState.slowMode);
if (seconds >= 60) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
if (remainingSeconds == 0) {
return '${minutes}m';
} else {
return '${minutes}m ${remainingSeconds}s';
}
}
return '${seconds}s';
}
Widget _buildModeChip({
required BuildContext context,
required IconData icon,
required IconData activeIcon,
required String label,
required String activeLabel,
required bool isActive,
required Color activeColor,
String? duration,
}) {
return Chip(
avatar: Icon(
isActive ? activeIcon : icon,
size: 16,
color: isActive ? activeColor : null,
),
label: duration != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message:
'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}',
child: Icon(
roomState.emoteOnly != '0'
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined,
color: roomState.emoteOnly != '0' ? Colors.yellow : disabledColor,
),
),
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: roomState.followersOnly == '-1'
? 'Followers-only mode off'
: roomState.followersOnly == '0'
? 'Followers-only mode on'
: 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})',
child: Icon(
roomState.followersOnly != '-1'
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
color: roomState.followersOnly != '-1' ? Colors.red : disabledColor,
),
),
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message:
'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}',
child: Text(
'R9K',
Text(isActive ? activeLabel : label),
const SizedBox(width: 4),
Text(
duration,
style: TextStyle(
color: roomState.r9k != '0' ? Colors.purple : disabledColor,
fontWeight: FontWeight.w500,
),
),
),
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message:
'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}',
child: Icon(
roomState.slowMode != '0'
? Icons.hourglass_top_rounded
: Icons.hourglass_empty_rounded,
color: roomState.slowMode != '0' ? Colors.blue : disabledColor,
),
),
Tooltip(
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}',
child: Icon(
roomState.subMode != '0'
? Icons.monetization_on_rounded
: Icons.monetization_on_outlined,
color: roomState.subMode != '0' ? Colors.green : disabledColor,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
)
: Text(isActive ? activeLabel : label),
side: BorderSide.none,
);
}
}

View file

@ -5,9 +5,10 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -46,8 +47,8 @@ class _ChattersListState extends State<ChattersList> {
decoration: InputDecoration(
prefixIcon: const Icon(Icons.filter_list_rounded),
hintText: 'Filter chatters',
suffixIcon: widget
.chatDetailsStore.textFieldFocusNode.hasFocus ||
suffixIcon:
widget.chatDetailsStore.textFieldFocusNode.hasFocus ||
widget.chatDetailsStore.filterText.isNotEmpty
? IconButton(
tooltip: widget.chatDetailsStore.filterText.isEmpty
@ -81,7 +82,7 @@ class _ChattersListState extends State<ChattersList> {
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Scrollbar(
FrostyScrollbar(
controller: widget.chatDetailsStore.scrollController,
child: Observer(
builder: (context) {
@ -108,11 +109,14 @@ class _ChattersListState extends State<ChattersList> {
if (widget.chatDetailsStore.chatUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child:
AlertMessage(message: 'No chatters found'),
child: AlertMessage(
message: 'No chatters found',
),
)
else if (widget
.chatDetailsStore.filteredUsers.isEmpty)
.chatDetailsStore
.filteredUsers
.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(
@ -133,19 +137,18 @@ class _ChattersListState extends State<ChattersList> {
),
),
onTap: () async {
final userInfo =
await context.read<TwitchApi>().getUser(
headers: context
.read<AuthStore>()
.headersTwitch,
userLogin: widget.chatDetailsStore
final userInfo = await context
.read<TwitchApi>()
.getUser(
userLogin: widget
.chatDetailsStore
.filteredUsers
.elementAt(index),
);
if (!context.mounted) return;
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
@ -158,7 +161,9 @@ class _ChattersListState extends State<ChattersList> {
},
),
itemCount: widget
.chatDetailsStore.filteredUsers.length,
.chatDetailsStore
.filteredUsers
.length,
),
],
);

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_section.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/page_view.dart';
import 'package:frosty/widgets/frosty_page_view.dart';
class EmoteMenuPanel extends StatelessWidget {
final ChatStore chatStore;
@ -42,14 +42,8 @@ class EmoteMenuPanel extends StatelessWidget {
headers: [if (channelEmotes.isNotEmpty) 'Channel', 'Global'],
children: [
if (channelEmotes.isNotEmpty)
EmoteMenuSection(
chatStore: chatStore,
emotes: channelEmotes,
),
EmoteMenuSection(
chatStore: chatStore,
emotes: globalEmotes,
),
EmoteMenuSection(chatStore: chatStore, emotes: channelEmotes),
EmoteMenuSection(chatStore: chatStore, emotes: globalEmotes),
],
);
} else {
@ -60,8 +54,9 @@ class EmoteMenuPanel extends StatelessWidget {
);
return FrostyPageView(
headers:
twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(),
headers: twitchEmotes!.keys
.map((header) => header.split(' ')[0])
.toList(),
children: twitchEmotes!.entries
.map(
(e) => EmoteMenuSection(

View file

@ -4,9 +4,8 @@ import 'package:frosty/constants.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:provider/provider.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
class EmoteMenuSection extends StatefulWidget {
final ChatStore chatStore;
@ -30,15 +29,18 @@ class _EmoteMenuSectionState extends State<EmoteMenuSection>
Widget build(BuildContext context) {
super.build(context);
return GridView.builder(
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
MediaQuery.of(context).orientation == Orientation.portrait
crossAxisCount: context.isPortrait
? 8
: context.read<SettingsStore>().showVideo
: context.settingsStore.showVideo
? 6
: 16,
),
padding: EdgeInsets.zero,
itemBuilder: (context, index) => InkWell(
onTap: widget.disabled
? null
@ -69,6 +71,7 @@ class _EmoteMenuSectionState extends State<EmoteMenuSection>
),
),
itemCount: widget.emotes.length,
),
);
}

View file

@ -5,23 +5,50 @@ import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
class RecentEmotesPanel extends StatelessWidget {
final ChatStore chatStore;
const RecentEmotesPanel({
super.key,
required this.chatStore,
});
const RecentEmotesPanel({super.key, required this.chatStore});
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Clear recent emotes',
message: 'Are you sure you want to clear your recent emotes?',
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
chatStore.assetsStore.recentEmotes.clear();
chatStore.updateNotification('Recent emotes cleared');
Navigator.pop(context);
},
child: const Text('Clear'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
return CustomScrollView(
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: CustomScrollView(
slivers: [
if (chatStore.assetsStore.recentEmotes.isEmpty)
const SliverFillRemaining(
@ -31,8 +58,7 @@ class RecentEmotesPanel extends StatelessWidget {
else
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
MediaQuery.of(context).orientation == Orientation.portrait
crossAxisCount: context.isPortrait
? 8
: context.read<SettingsStore>().showVideo
? 6
@ -76,7 +102,8 @@ class RecentEmotesPanel extends StatelessWidget {
colorBlendMode: matchingEmotes.isNotEmpty
? null
: BlendMode.modulate,
height: emote.height?.toDouble() ?? defaultEmoteSize,
height:
emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(),
),
),
@ -85,7 +112,21 @@ class RecentEmotesPanel extends StatelessWidget {
},
itemCount: chatStore.assetsStore.recentEmotes.length,
),
SliverFillRemaining(
hasScrollBody: false,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: TextButton.icon(
onPressed: () => _showClearDialog(context),
icon: const Icon(Icons.clear_all_rounded),
label: const Text('Clear recent emotes'),
),
),
),
),
],
),
);
},
);

View file

@ -6,7 +6,9 @@ import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/shared_chat_session.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/stores/global_assets_store.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -19,35 +21,61 @@ abstract class ChatAssetsStoreBase with Store {
final BTTVApi bttvApi;
final FFZApi ffzApi;
final SevenTVApi sevenTVApi;
final GlobalAssetsStore globalAssetsStore;
/// Contains any custom FFZ mod and vip badges for the channel.
RoomFFZ? ffzRoomInfo;
String? sevenTvEmoteSetId;
/// Tracks shared chat participant channels that we've already loaded assets for.
final Set<String> _loadedSharedChannelIds = <String>{};
/// Optional: Track 7TV set ids for shared chat participants (keyed by broadcaster id).
final sharedSevenTvSetIds = ObservableMap<String, String>();
final channelIdToUserTwitch = ObservableMap<String, UserTwitch>();
@computed
List<Emote> get bttvEmotes =>
_emoteToObject.values.where((emote) => isBTTV(emote)).toList();
emoteToObject.values.where((emote) => isBTTV(emote)).toList();
@computed
List<Emote> get ffzEmotes =>
_emoteToObject.values.where((emote) => isFFZ(emote)).toList();
emoteToObject.values.where((emote) => isFFZ(emote)).toList();
@computed
List<Emote> get sevenTVEmotes =>
_emoteToObject.values.where((emote) => is7TV(emote)).toList();
/// The map of badges ids to their object representation.
final twitchBadgesToObject = ObservableMap<String, ChatBadge>();
emoteToObject.values.where((emote) => is7TV(emote)).toList();
@readonly
var _recentEmotes = ObservableList<Emote>();
/// The map of emote words to their image or GIF URL. May be used by anyone in the chat.
/// Channel-specific emotes only (not global).
@readonly
var _emoteToObject = ObservableMap<String, Emote>();
var _channelEmoteToObject = ObservableMap<String, Emote>();
/// Channel-specific Twitch badges (overrides globals).
@readonly
var _channelTwitchBadges = <String, ChatBadge>{};
/// Combined emotes: global + channel. Channel emotes take precedence.
@computed
Map<String, Emote> get emoteToObject {
final combined = Map<String, Emote>.from(globalAssetsStore.globalEmoteMap);
combined.addAll(_channelEmoteToObject);
return combined;
}
/// Combined Twitch badges: global + channel. Channel badges take precedence.
@computed
Map<String, ChatBadge> get twitchBadgesToObject {
final combined = Map<String, ChatBadge>.from(
globalAssetsStore.twitchGlobalBadges,
);
combined.addAll(_channelTwitchBadges);
return combined;
}
/// The emotes that are "owned" and may be used by the current user.
@readonly
@ -59,17 +87,16 @@ abstract class ChatAssetsStoreBase with Store {
// ignore: prefer_final_fields
var _userEmoteSectionToEmotes = <String, List<Emote>>{};
/// The map of user IDs to their FFZ badges.
@readonly
var _userToFFZBadges = <String, List<ChatBadge>>{};
/// The map of user IDs to their FFZ badges (from global store).
Map<String, List<ChatBadge>> get userToFFZBadges =>
globalAssetsStore.ffzBadges;
/// The map of user IDs to their 7TV badges.
@readonly
var _userTo7TVBadges = <String, List<ChatBadge>>{};
/// The map of user IDs to their BTTV badge.
@readonly
var _userToBTTVBadges = <String, ChatBadge>{};
/// The map of user IDs to their BTTV badge (from global store).
Map<String, ChatBadge> get userToBTTVBadges => globalAssetsStore.bttvBadges;
/// Whether or not the emote menu is visible.
@observable
@ -98,6 +125,7 @@ abstract class ChatAssetsStoreBase with Store {
required this.bttvApi,
required this.ffzApi,
required this.sevenTVApi,
required this.globalAssetsStore,
});
@action
@ -105,7 +133,8 @@ abstract class ChatAssetsStoreBase with Store {
// Retrieve the instance that will allow us to retrieve local search history.
final prefs = await SharedPreferences.getInstance();
_recentEmotes = prefs
_recentEmotes =
prefs
.getStringList('recent_emotes')
?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson)))
.toList()
@ -121,7 +150,8 @@ abstract class ChatAssetsStoreBase with Store {
});
}
/// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl]
/// Fetches channel-specific assets (badges and emotes).
/// Global assets are loaded via [globalAssetsStore] and combined automatically.
@action
Future<void> assetsFuture({
required String channelId,
@ -135,97 +165,230 @@ abstract class ChatAssetsStoreBase with Store {
bool showBTTVBadges = true,
bool showFFZEmotes = true,
bool showFFZBadges = true,
}) =>
// Fetch the global and channel's assets (emotes & badges).
// Async awaits are placed in a list so they are performed in parallel.
//
// Emotes
}) async {
// Fetch global (cached) and channel-specific assets in parallel
await Future.wait([
// Global assets (cached across all tabs - only fetches once)
globalAssetsStore.ensureLoaded(
showTwitchEmotes: showTwitchEmotes,
showTwitchBadges: showTwitchBadges,
show7TVEmotes: show7TVEmotes,
showBTTVEmotes: showBTTVEmotes,
showBTTVBadges: showBTTVBadges,
showFFZEmotes: showFFZEmotes,
showFFZBadges: showFFZBadges,
),
// Channel-specific emotes
Future.wait([
Future.wait([
if (showTwitchEmotes) ...[
if (showTwitchEmotes)
twitchApi
.getEmotesGlobal(headers: headers)
.catchError(onEmoteError),
twitchApi
.getEmotesChannel(id: channelId, headers: headers)
.getEmotesChannel(id: channelId)
.then((emotes) {
_userEmoteSectionToEmotes.update(
'Channel Emotes',
(existingEmoteSet) => [...existingEmoteSet, ...emotes],
ifAbsent: () => emotes.toList(),
);
return emotes;
}).catchError(onEmoteError),
],
if (show7TVEmotes) ...[
sevenTVApi.getEmotesGlobal().catchError(onEmoteError),
sevenTVApi.getEmotesChannel(id: channelId).then((data) {
})
.catchError(onEmoteError),
if (show7TVEmotes)
sevenTVApi
.getEmotesChannel(id: channelId)
.then((data) {
final (setId, emotes) = data;
sevenTvEmoteSetId = setId;
return emotes;
}).catchError(onEmoteError),
],
if (showBTTVEmotes) ...[
bttvApi.getEmotesGlobal().catchError(onEmoteError),
})
.catchError(onEmoteError),
if (showBTTVEmotes)
bttvApi.getEmotesChannel(id: channelId).catchError(onEmoteError),
],
if (showFFZEmotes) ...[
ffzApi.getEmotesGlobal().catchError(onEmoteError),
ffzApi.getRoomInfo(id: channelId).then((ffzRoom) {
if (showFFZEmotes)
ffzApi
.getRoomInfo(id: channelId)
.then((ffzRoom) {
final (roomInfo, emotes) = ffzRoom;
ffzRoomInfo = roomInfo;
return emotes;
}).catchError(onEmoteError),
],
]).then((assets) => assets.expand((list) => list)).then(
(emotes) => _emoteToObject = {
})
.catchError(onEmoteError),
])
.then((assets) => assets.expand((list) => list))
.then(
(emotes) => _channelEmoteToObject = <String, Emote>{
for (final emote in emotes) emote.name: emote,
}.asObservable(),
),
// Badges
Future.wait([
twitchApi
.getSharedChatSession(
broadcasterId: channelId,
headers: headers,
)
.then((sharedChatSession) {
if (sharedChatSession == null) return;
for (final participant in sharedChatSession.participants) {
twitchApi
.getUser(id: participant.broadcasterId, headers: headers)
.then((user) {
channelIdToUserTwitch[participant.broadcasterId] = user;
});
}
}).catchError(onBadgeError),
// Get global badges first, then channel badges to avoid badge conflicts.
// We want the channel badges to override the global badges.
// Channel badges (Twitch only - FFZ/BTTV badges are global)
if (showTwitchBadges)
twitchApi
.getBadgesGlobal(headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.then(
(_) => twitchApi
.getBadgesChannel(id: channelId, headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.getBadgesChannel(id: channelId)
.then((badges) => _channelTwitchBadges = badges)
.catchError(onBadgeError),
),
if (showFFZBadges)
ffzApi
.getBadges()
.then((badges) => _userToFFZBadges = badges)
.catchError(onBadgeError),
if (showBTTVBadges)
bttvApi
.getBadges()
.then((badges) => _userToBTTVBadges = badges)
.catchError(onBadgeError),
]),
]);
}
/// Fetch and merge assets for all participants in a shared chat session.
/// Safe to call multiple times; only new participant channels will be fetched.
@action
Future<void> fetchSharedChatAssets({
required String channelId,
required Map<String, String> headers,
required Function onEmoteError,
required Function onBadgeError,
bool showTwitchEmotes = true,
bool showTwitchBadges = true,
bool show7TVEmotes = true,
bool showBTTVEmotes = true,
bool showFFZEmotes = true,
bool showFFZBadges = true,
bool force = false,
}) async {
if (force) {
_loadedSharedChannelIds.clear();
sharedSevenTvSetIds.clear();
}
final newParticipantIds = await populateSharedChatParticipants(
channelId: channelId,
onBadgeError: onBadgeError,
);
if (newParticipantIds.isEmpty) return;
// Fetch emotes and badges for all new participants in parallel.
final futures = <Future<void>>[];
// Add emote futures for each participant.
futures.addAll(
newParticipantIds.map(
(id) =>
_fetchEmotesForChannel(
id,
onEmoteError: onEmoteError,
showTwitchEmotes: showTwitchEmotes,
show7TVEmotes: show7TVEmotes,
showBTTVEmotes: showBTTVEmotes,
showFFZEmotes: showFFZEmotes,
).then((emotes) {
for (final emote in emotes) {
_channelEmoteToObject[emote.name] = emote;
}
}),
),
);
// Add badge futures for each participant.
if (showTwitchBadges) {
futures.addAll(
newParticipantIds.map(
(id) => _fetchBadgesForChannel(id, onBadgeError: onBadgeError),
),
);
}
await Future.wait(futures);
}
/// Load and cache shared chat participants for a broadcaster.
/// Returns the list of new participant broadcaster IDs (deduped).
@action
Future<List<String>> populateSharedChatParticipants({
required String channelId,
required Function onBadgeError,
}) async {
SharedChatSession? sharedSession;
try {
sharedSession = await twitchApi.getSharedChatSession(
broadcasterId: channelId,
);
} catch (e) {
onBadgeError(e);
return <String>[];
}
if (sharedSession == null) return <String>[];
final newParticipantIds = <String>[];
for (final participant in sharedSession.participants) {
final id = participant.broadcasterId;
if (_loadedSharedChannelIds.add(id)) {
newParticipantIds.add(id);
}
}
// Fetch user profiles concurrently.
if (newParticipantIds.isNotEmpty) {
await Future.wait(
newParticipantIds.map((id) async {
try {
final user = await twitchApi.getUser(id: id);
channelIdToUserTwitch[id] = user;
} catch (e) {
onBadgeError(e);
}
}),
);
}
return newParticipantIds;
}
Future<List<Emote>> _fetchEmotesForChannel(
String id, {
required Function onEmoteError,
bool showTwitchEmotes = true,
bool show7TVEmotes = true,
bool showBTTVEmotes = true,
bool showFFZEmotes = true,
}) async {
final futures = <Future<List<Emote>>>[];
if (showTwitchEmotes) {
futures.add(twitchApi.getEmotesChannel(id: id).catchError(onEmoteError));
}
if (show7TVEmotes) {
futures.add(
sevenTVApi
.getEmotesChannel(id: id)
.then((data) {
final (setId, emotes) = data;
sharedSevenTvSetIds[id] = setId;
return emotes;
})
.catchError(onEmoteError),
);
}
if (showBTTVEmotes) {
futures.add(bttvApi.getEmotesChannel(id: id).catchError(onEmoteError));
}
if (showFFZEmotes) {
futures.add(
ffzApi
.getRoomInfo(id: id)
.then((ffzRoom) {
final (roomInfoIgnored, emotes) = ffzRoom;
return emotes;
})
.catchError(onEmoteError),
);
}
final lists = await Future.wait(futures);
return lists.expand((e) => e).toList();
}
Future<void> _fetchBadgesForChannel(
String id, {
required Function onBadgeError,
}) async {
try {
final badges = await twitchApi.getBadgesChannel(id: id);
_channelTwitchBadges = {..._channelTwitchBadges, ...badges};
} catch (e) {
onBadgeError(e);
}
}
@action
Future<void> userEmotesFuture({
@ -235,9 +398,7 @@ abstract class ChatAssetsStoreBase with Store {
}) async {
final userEmotes = await Future.wait(
emoteSets.map(
(setId) => twitchApi
.getEmotesSets(setId: setId, headers: headers)
.catchError(onError),
(setId) => twitchApi.getEmotesSets(setId: setId).catchError(onError),
),
);
@ -254,10 +415,7 @@ abstract class ChatAssetsStoreBase with Store {
ifAbsent: () => emoteSet,
);
} else {
final owner = await twitchApi.getUser(
id: ownerId,
headers: headers,
);
final owner = await twitchApi.getUser(id: ownerId);
_userEmoteSectionToEmotes.update(
owner.displayName,
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],

View file

@ -12,27 +12,46 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
Computed<List<Emote>>? _$bttvEmotesComputed;
@override
List<Emote> get bttvEmotes =>
(_$bttvEmotesComputed ??= Computed<List<Emote>>(() => super.bttvEmotes,
name: 'ChatAssetsStoreBase.bttvEmotes'))
.value;
List<Emote> get bttvEmotes => (_$bttvEmotesComputed ??= Computed<List<Emote>>(
() => super.bttvEmotes,
name: 'ChatAssetsStoreBase.bttvEmotes',
)).value;
Computed<List<Emote>>? _$ffzEmotesComputed;
@override
List<Emote> get ffzEmotes =>
(_$ffzEmotesComputed ??= Computed<List<Emote>>(() => super.ffzEmotes,
name: 'ChatAssetsStoreBase.ffzEmotes'))
.value;
List<Emote> get ffzEmotes => (_$ffzEmotesComputed ??= Computed<List<Emote>>(
() => super.ffzEmotes,
name: 'ChatAssetsStoreBase.ffzEmotes',
)).value;
Computed<List<Emote>>? _$sevenTVEmotesComputed;
@override
List<Emote> get sevenTVEmotes => (_$sevenTVEmotesComputed ??=
Computed<List<Emote>>(() => super.sevenTVEmotes,
name: 'ChatAssetsStoreBase.sevenTVEmotes'))
.value;
List<Emote> get sevenTVEmotes =>
(_$sevenTVEmotesComputed ??= Computed<List<Emote>>(
() => super.sevenTVEmotes,
name: 'ChatAssetsStoreBase.sevenTVEmotes',
)).value;
Computed<Map<String, Emote>>? _$emoteToObjectComputed;
late final _$_recentEmotesAtom =
Atom(name: 'ChatAssetsStoreBase._recentEmotes', context: context);
@override
Map<String, Emote> get emoteToObject =>
(_$emoteToObjectComputed ??= Computed<Map<String, Emote>>(
() => super.emoteToObject,
name: 'ChatAssetsStoreBase.emoteToObject',
)).value;
Computed<Map<String, ChatBadge>>? _$twitchBadgesToObjectComputed;
@override
Map<String, ChatBadge> get twitchBadgesToObject =>
(_$twitchBadgesToObjectComputed ??= Computed<Map<String, ChatBadge>>(
() => super.twitchBadgesToObject,
name: 'ChatAssetsStoreBase.twitchBadgesToObject',
)).value;
late final _$_recentEmotesAtom = Atom(
name: 'ChatAssetsStoreBase._recentEmotes',
context: context,
);
ObservableList<Emote> get recentEmotes {
_$_recentEmotesAtom.reportRead();
@ -49,26 +68,59 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
});
}
late final _$_emoteToObjectAtom =
Atom(name: 'ChatAssetsStoreBase._emoteToObject', context: context);
late final _$_channelEmoteToObjectAtom = Atom(
name: 'ChatAssetsStoreBase._channelEmoteToObject',
context: context,
);
ObservableMap<String, Emote> get emoteToObject {
_$_emoteToObjectAtom.reportRead();
return super._emoteToObject;
ObservableMap<String, Emote> get channelEmoteToObject {
_$_channelEmoteToObjectAtom.reportRead();
return super._channelEmoteToObject;
}
@override
ObservableMap<String, Emote> get _emoteToObject => emoteToObject;
ObservableMap<String, Emote> get _channelEmoteToObject =>
channelEmoteToObject;
@override
set _emoteToObject(ObservableMap<String, Emote> value) {
_$_emoteToObjectAtom.reportWrite(value, super._emoteToObject, () {
super._emoteToObject = value;
});
set _channelEmoteToObject(ObservableMap<String, Emote> value) {
_$_channelEmoteToObjectAtom.reportWrite(
value,
super._channelEmoteToObject,
() {
super._channelEmoteToObject = value;
},
);
}
late final _$_userEmoteToObjectAtom =
Atom(name: 'ChatAssetsStoreBase._userEmoteToObject', context: context);
late final _$_channelTwitchBadgesAtom = Atom(
name: 'ChatAssetsStoreBase._channelTwitchBadges',
context: context,
);
Map<String, ChatBadge> get channelTwitchBadges {
_$_channelTwitchBadgesAtom.reportRead();
return super._channelTwitchBadges;
}
@override
Map<String, ChatBadge> get _channelTwitchBadges => channelTwitchBadges;
@override
set _channelTwitchBadges(Map<String, ChatBadge> value) {
_$_channelTwitchBadgesAtom.reportWrite(
value,
super._channelTwitchBadges,
() {
super._channelTwitchBadges = value;
},
);
}
late final _$_userEmoteToObjectAtom = Atom(
name: 'ChatAssetsStoreBase._userEmoteToObject',
context: context,
);
Map<String, Emote> get userEmoteToObject {
_$_userEmoteToObjectAtom.reportRead();
@ -86,7 +138,9 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
}
late final _$_userEmoteSectionToEmotesAtom = Atom(
name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', context: context);
name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes',
context: context,
);
Map<String, List<Emote>> get userEmoteSectionToEmotes {
_$_userEmoteSectionToEmotesAtom.reportRead();
@ -99,32 +153,19 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
@override
set _userEmoteSectionToEmotes(Map<String, List<Emote>> value) {
_$_userEmoteSectionToEmotesAtom
.reportWrite(value, super._userEmoteSectionToEmotes, () {
_$_userEmoteSectionToEmotesAtom.reportWrite(
value,
super._userEmoteSectionToEmotes,
() {
super._userEmoteSectionToEmotes = value;
});
},
);
}
late final _$_userToFFZBadgesAtom =
Atom(name: 'ChatAssetsStoreBase._userToFFZBadges', context: context);
Map<String, List<ChatBadge>> get userToFFZBadges {
_$_userToFFZBadgesAtom.reportRead();
return super._userToFFZBadges;
}
@override
Map<String, List<ChatBadge>> get _userToFFZBadges => userToFFZBadges;
@override
set _userToFFZBadges(Map<String, List<ChatBadge>> value) {
_$_userToFFZBadgesAtom.reportWrite(value, super._userToFFZBadges, () {
super._userToFFZBadges = value;
});
}
late final _$_userTo7TVBadgesAtom =
Atom(name: 'ChatAssetsStoreBase._userTo7TVBadges', context: context);
late final _$_userTo7TVBadgesAtom = Atom(
name: 'ChatAssetsStoreBase._userTo7TVBadges',
context: context,
);
Map<String, List<ChatBadge>> get userTo7TVBadges {
_$_userTo7TVBadgesAtom.reportRead();
@ -141,26 +182,10 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
});
}
late final _$_userToBTTVBadgesAtom =
Atom(name: 'ChatAssetsStoreBase._userToBTTVBadges', context: context);
Map<String, ChatBadge> get userToBTTVBadges {
_$_userToBTTVBadgesAtom.reportRead();
return super._userToBTTVBadges;
}
@override
Map<String, ChatBadge> get _userToBTTVBadges => userToBTTVBadges;
@override
set _userToBTTVBadges(Map<String, ChatBadge> value) {
_$_userToBTTVBadgesAtom.reportWrite(value, super._userToBTTVBadges, () {
super._userToBTTVBadges = value;
});
}
late final _$showEmoteMenuAtom =
Atom(name: 'ChatAssetsStoreBase.showEmoteMenu', context: context);
late final _$showEmoteMenuAtom = Atom(
name: 'ChatAssetsStoreBase.showEmoteMenu',
context: context,
);
@override
bool get showEmoteMenu {
@ -175,32 +200,24 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
});
}
late final _$initAsyncAction =
AsyncAction('ChatAssetsStoreBase.init', context: context);
late final _$initAsyncAction = AsyncAction(
'ChatAssetsStoreBase.init',
context: context,
);
@override
Future<void> init() {
return _$initAsyncAction.run(() => super.init());
}
late final _$userEmotesFutureAsyncAction =
AsyncAction('ChatAssetsStoreBase.userEmotesFuture', context: context);
late final _$assetsFutureAsyncAction = AsyncAction(
'ChatAssetsStoreBase.assetsFuture',
context: context,
);
@override
Future<void> userEmotesFuture(
{required List<String> emoteSets,
required Map<String, String> headers,
required Function onError}) {
return _$userEmotesFutureAsyncAction.run(() => super.userEmotesFuture(
emoteSets: emoteSets, headers: headers, onError: onError));
}
late final _$ChatAssetsStoreBaseActionController =
ActionController(name: 'ChatAssetsStoreBase', context: context);
@override
Future<void> assetsFuture(
{required String channelId,
Future<void> assetsFuture({
required String channelId,
required Map<String, String> headers,
required Function onEmoteError,
required Function onBadgeError,
@ -210,11 +227,10 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
bool showBTTVEmotes = true,
bool showBTTVBadges = true,
bool showFFZEmotes = true,
bool showFFZBadges = true}) {
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
name: 'ChatAssetsStoreBase.assetsFuture');
try {
return super.assetsFuture(
bool showFFZBadges = true,
}) {
return _$assetsFutureAsyncAction.run(
() => super.assetsFuture(
channelId: channelId,
headers: headers,
onEmoteError: onEmoteError,
@ -225,10 +241,83 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
showBTTVEmotes: showBTTVEmotes,
showBTTVBadges: showBTTVBadges,
showFFZEmotes: showFFZEmotes,
showFFZBadges: showFFZBadges);
} finally {
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
showFFZBadges: showFFZBadges,
),
);
}
late final _$fetchSharedChatAssetsAsyncAction = AsyncAction(
'ChatAssetsStoreBase.fetchSharedChatAssets',
context: context,
);
@override
Future<void> fetchSharedChatAssets({
required String channelId,
required Map<String, String> headers,
required Function onEmoteError,
required Function onBadgeError,
bool showTwitchEmotes = true,
bool showTwitchBadges = true,
bool show7TVEmotes = true,
bool showBTTVEmotes = true,
bool showFFZEmotes = true,
bool showFFZBadges = true,
bool force = false,
}) {
return _$fetchSharedChatAssetsAsyncAction.run(
() => super.fetchSharedChatAssets(
channelId: channelId,
headers: headers,
onEmoteError: onEmoteError,
onBadgeError: onBadgeError,
showTwitchEmotes: showTwitchEmotes,
showTwitchBadges: showTwitchBadges,
show7TVEmotes: show7TVEmotes,
showBTTVEmotes: showBTTVEmotes,
showFFZEmotes: showFFZEmotes,
showFFZBadges: showFFZBadges,
force: force,
),
);
}
late final _$populateSharedChatParticipantsAsyncAction = AsyncAction(
'ChatAssetsStoreBase.populateSharedChatParticipants',
context: context,
);
@override
Future<List<String>> populateSharedChatParticipants({
required String channelId,
required Function onBadgeError,
}) {
return _$populateSharedChatParticipantsAsyncAction.run(
() => super.populateSharedChatParticipants(
channelId: channelId,
onBadgeError: onBadgeError,
),
);
}
late final _$userEmotesFutureAsyncAction = AsyncAction(
'ChatAssetsStoreBase.userEmotesFuture',
context: context,
);
@override
Future<void> userEmotesFuture({
required List<String> emoteSets,
required Map<String, String> headers,
required Function onError,
}) {
return _$userEmotesFutureAsyncAction.run(
() => super.userEmotesFuture(
emoteSets: emoteSets,
headers: headers,
onError: onError,
),
);
}
@override
@ -237,7 +326,9 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
showEmoteMenu: ${showEmoteMenu},
bttvEmotes: ${bttvEmotes},
ffzEmotes: ${ffzEmotes},
sevenTVEmotes: ${sevenTVEmotes}
sevenTVEmotes: ${sevenTVEmotes},
emoteToObject: ${emoteToObject},
twitchBadgesToObject: ${twitchBadgesToObject}
''';
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
@ -13,7 +14,6 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/utils.dart';
import 'package:mobx/mobx.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
part 'chat_store.g.dart';
@ -97,9 +97,30 @@ abstract class ChatStoreBase with Store {
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
final messageBuffer = ObservableList<IRCMessage>();
/// The set of message IDs that have been revealed by the user (for deleted messages).
final revealedMessageIds = ObservableSet<String>();
@action
void revealMessage(String id) {
revealedMessageIds.add(id);
}
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
/// Timer used for resetting the sending state if no USERSTATE is received.
Timer? _sendingTimeoutTimer;
/// Timer used for updating the chat delay countdown message.
Timer? _chatDelayCountdownTimer;
/// Reference to the current countdown message for direct updates (avoids O(n) search).
IRCMessage? _countdownMessage;
/// Tracks whether the initial chat delay sync has completed for the current session.
/// Prevents the countdown from re-appearing when latency updates arrive after initial sync.
bool _chatDelaySyncCompleted = false;
/// The current timer for the sleep timer if active.
Timer? sleepTimer;
@ -146,6 +167,14 @@ abstract class ChatStoreBase with Store {
@readonly
var _showMentionAutocomplete = false;
/// Whether a message is currently being sent and waiting for server confirmation.
@readonly
var _isSendingMessage = false;
/// Whether the chat is currently in shared chat mode (based on source-room-id tag presence).
@readonly
var _isInSharedChatMode = false;
/// The logged-in user's appearance in chat.
@readonly
var _userState = const USERSTATE();
@ -156,6 +185,12 @@ abstract class ChatStoreBase with Store {
@observable
IRCMessage? replyingToMessage;
/// Public getter for whether a message is currently being sent.
bool get isSendingMessage => _isSendingMessage;
/// Public getter for whether the chat is in shared chat mode.
bool get isInSharedChatMode => _isInSharedChatMode;
ChatStoreBase({
required this.twitchApi,
required this.auth,
@ -166,16 +201,10 @@ abstract class ChatStoreBase with Store {
required this.channelId,
required this.displayName,
}) {
// Enable wakelock to prevent the chat from sleeping.
if (settings.chatOnlyPreventSleep) WakelockPlus.enable();
// Create a reaction that will reconnect to chat when logging in or out.
// Closing the channel will trigger a reconnect with the new credentials.
reactions.add(
reaction(
(_) => auth.isLoggedIn,
(_) => _channel?.sink.close(1000),
),
reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)),
);
reactions.add(
@ -206,43 +235,38 @@ abstract class ChatStoreBase with Store {
),
);
// Start chat delay countdown when toggling video on, cancel when off
reactions.add(
reaction((_) => settings.showVideo, (showVideo) {
if (showVideo && settings.chatDelay > 0) {
_startChatDelayCountdown();
} else if (!showVideo) {
_cancelChatDelayCountdown();
_chatDelaySyncCompleted = false;
}
}),
);
// Start chat delay countdown when chatDelay is set (for auto sync mode)
// Cancel countdown if delay becomes 0
reactions.add(
reaction((_) => settings.chatDelay, (chatDelay) {
if (chatDelay == 0) {
_cancelChatDelayCountdown();
_chatDelaySyncCompleted = false;
} else if (settings.autoSyncChatDelay &&
settings.showVideo &&
chatDelay > 0 &&
!_chatDelaySyncCompleted) {
_startChatDelayCountdown();
}
}),
);
assetsStore.init();
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.showVideo && settings.chatDelay > 0) {
_messages.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
),
);
}
reactions.add(
reaction(
(_) => settings.showVideo,
(showVideo) {
if (settings.chatDelay > 0) {
if (showVideo) {
_messages.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
),
);
} else {
_messages.add(
IRCMessage.createNotice(
message: 'Removing message delay...',
),
);
}
}
},
),
);
if (settings.showRecentMessages) {
getRecentMessage().then((_) => connectToChat());
} else {
@ -275,12 +299,14 @@ abstract class ChatStoreBase with Store {
textController.addListener(() {
_inputText = textController.text;
_showEmoteAutocomplete = !_showMentionAutocomplete &&
_showEmoteAutocomplete =
!_showMentionAutocomplete &&
textFieldFocusNode.hasFocus &&
textController.text.split(' ').last.isNotEmpty;
_showSendButton = textController.text.isNotEmpty;
_showMentionAutocomplete = textFieldFocusNode.hasFocus &&
_showMentionAutocomplete =
textFieldFocusNode.hasFocus &&
textController.text.split(' ').last.startsWith('@');
});
}
@ -296,8 +322,10 @@ abstract class ChatStoreBase with Store {
for (final message in data.trimRight().split('\r\n')) {
// debugPrint('$message\n');
if (message.startsWith('@')) {
final parsedIRCMessage =
IRCMessage.fromString(message, userLogin: auth.user.details?.login);
final parsedIRCMessage = IRCMessage.fromString(
message,
userLogin: auth.user.details?.login,
);
if (parsedIRCMessage.user != null) {
chatDetailsStore.chatUsers.add(parsedIRCMessage.user!);
@ -334,6 +362,32 @@ abstract class ChatStoreBase with Store {
case Command.privateMessage:
case Command.notice:
case Command.userNotice:
// Update shared chat mode based on source-room-id tag presence
final wasShared = _isInSharedChatMode;
_isInSharedChatMode = parsedIRCMessage.tags.containsKey(
'source-room-id',
);
// On transition into shared chat mode, fetch assets for participants.
if (!wasShared && _isInSharedChatMode) {
assetsStore.fetchSharedChatAssets(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
debugPrint(error.toString());
return <ChatBadge>[];
},
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
);
}
messageBuffer.add(parsedIRCMessage);
break;
case Command.clearChat:
@ -351,8 +405,8 @@ abstract class ChatStoreBase with Store {
);
break;
case Command.roomState:
chatDetailsStore.roomState =
chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage);
chatDetailsStore.roomState = chatDetailsStore.roomState
.fromIRCMessage(parsedIRCMessage);
continue;
case Command.userState:
_userState = _userState.fromIRCMessage(parsedIRCMessage);
@ -362,6 +416,9 @@ abstract class ChatStoreBase with Store {
textController.clear();
messageBuffer.add(toSend!);
toSend = null;
// Reset sending state since message was successfully sent
_sendingTimeoutTimer?.cancel();
_isSendingMessage = false;
}
break;
case Command.globalUserState:
@ -406,20 +463,21 @@ abstract class ChatStoreBase with Store {
);
// Activate the message buffer.
// Cancel any existing timer before creating a new one to prevent duplicates on reconnect.
_messageBufferTimer?.cancel();
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(timer) => addMessages(),
);
getAssets().then((_) {
if (!settings.show7TVEmotes) return;
// Set up 7TV real-time listener (assets already fetched in connectToChat)
if (settings.show7TVEmotes) {
final emoteSetId = assetsStore.sevenTvEmoteSetId;
if (emoteSetId != null && !_shouldDisconnect) {
listenToSevenTVEmoteSet(emoteSetId: emoteSetId);
}
});
}
// Reset exponential backoff if successfully connected.
_retries = 0;
@ -430,17 +488,23 @@ abstract class ChatStoreBase with Store {
// Fetch the assets used in chat including badges and emotes.
@action
Future<void> getAssets() async => assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: (error) {
Future<void> getAssets() async {
// Prepare common error handlers to avoid repetition.
List<Emote> onEmoteError(dynamic error) {
debugPrint(error.toString());
return <Emote>[];
},
onBadgeError: (error) {
}
List<ChatBadge> onBadgeError(dynamic error) {
debugPrint(error.toString());
return <ChatBadge>[];
},
}
final baseAssets = assetsStore.assetsFuture(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: onEmoteError,
onBadgeError: onBadgeError,
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
@ -450,6 +514,27 @@ abstract class ChatStoreBase with Store {
showFFZBadges: settings.showFFZBadges,
);
// If shared chat mode is active, also refresh participant assets in parallel.
if (_isInSharedChatMode) {
final sharedAssets = assetsStore.fetchSharedChatAssets(
channelId: channelId,
headers: auth.headersTwitch,
onEmoteError: onEmoteError,
onBadgeError: onBadgeError,
showTwitchEmotes: settings.showTwitchEmotes,
showTwitchBadges: settings.showTwitchBadges,
show7TVEmotes: settings.show7TVEmotes,
showBTTVEmotes: settings.showBTTVEmotes,
showFFZEmotes: settings.showFFZEmotes,
showFFZBadges: settings.showFFZBadges,
force: true,
);
await Future.wait([baseAssets, sharedAssets]);
} else {
await baseAssets;
}
}
/// Re-enables [_autoScroll] and jumps to the latest message.
@action
void resumeScroll() {
@ -475,8 +560,13 @@ abstract class ChatStoreBase with Store {
);
_sevenTVChannel?.sink.close(1000);
_sevenTVChannel =
WebSocketChannel.connect(Uri.parse('wss://events.7tv.io/v3'));
_sevenTVChannel = WebSocketChannel.connect(
Uri.parse('wss://events.7tv.io/v3'),
);
// Track the current connection to prevent stale delayed callbacks
final connectionId = DateTime.now().millisecondsSinceEpoch;
var currentConnectionId = connectionId;
void listener(dynamic data) {
// debugPrint(data);
@ -527,24 +617,47 @@ abstract class ChatStoreBase with Store {
if (!settings.showVideo || settings.chatDelay == 0) {
listener(data);
} else {
Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => listener(data),
);
final capturedConnectionId = currentConnectionId;
Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () {
// Only process if this is still the active connection
if (capturedConnectionId == currentConnectionId) {
listener(data);
}
});
}
},
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
onDone: () => debugPrint('7TV events done'),
onDone: () {
// Invalidate the current connection to cancel pending delayed callbacks
currentConnectionId = 0;
debugPrint('7TV events done');
},
);
_sevenTVChannel?.sink.add(jsonEncode(subscribePayload));
}
@action
void connectToChat() {
Future<void> connectToChat({bool isReconnect = false}) async {
// Fetch assets first so they're available for all messages
getAssets();
// Cancel existing listener to prevent duplicate message processing
_channelListener?.cancel();
_channel?.sink.close(1000);
_channel =
WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
_channel = WebSocketChannel.connect(
Uri.parse('wss://irc-ws.chat.twitch.tv:443'),
);
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
if (!isReconnect && settings.showVideo && settings.chatDelay > 0) {
_startChatDelayCountdown();
}
// Track the current connection to prevent stale delayed callbacks
final connectionId = DateTime.now().millisecondsSinceEpoch;
var currentConnectionId = connectionId;
// Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen(
@ -552,49 +665,94 @@ abstract class ChatStoreBase with Store {
if (!settings.showVideo || settings.chatDelay == 0) {
_handleIRCData(data.toString());
} else {
Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString()),
);
final capturedConnectionId = currentConnectionId;
Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () {
// Only process if this is still the active connection
if (capturedConnectionId == currentConnectionId) {
_handleIRCData(data.toString());
}
});
}
},
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async {
// Invalidate the current connection to cancel pending delayed callbacks
currentConnectionId = 0;
// Cancel chat delay countdown when disconnecting
_cancelChatDelayCountdown();
_chatDelaySyncCompleted = false;
if (_shouldDisconnect) {
_sevenTVChannel?.sink.close(1000);
return;
}
if (_retries >= _maxRetries) {
messageBuffer.add(
// Add directly to messages, not buffer, so it shows immediately
_messages.add(
IRCMessage.createNotice(
message: 'Disconnected from chat',
message: 'Chat disconnected. Please check your connection.',
actionCallback: () {
_retries = 0;
_backoffTime = 0;
connectToChat(isReconnect: true);
},
actionLabel: 'Reconnect',
),
);
return;
}
if (_backoffTime > 0) {
// Add notice that chat was disconnected and then wait the backoff time before reconnecting.
final notice =
'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...';
messageBuffer.add(IRCMessage.createNotice(message: notice));
}
// Show countdown message for backoff time
var remainingSeconds = _backoffTime;
_messages.add(
IRCMessage.createNotice(
message:
'Connection lost. Reconnecting in ${remainingSeconds}s...',
),
);
await Future.delayed(Duration(seconds: _backoffTime));
// Update countdown every second
await Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
remainingSeconds--;
// Find and replace the countdown message
final index = _messages.indexWhere(
(msg) =>
msg.message?.contains('Connection lost. Reconnecting in') ??
false,
);
if (index != -1 && remainingSeconds > 0) {
_messages[index] = IRCMessage.createNotice(
message:
'Connection lost. Reconnecting in ${remainingSeconds}s...',
);
return true; // Continue loop
} else {
// Remove countdown message when done
if (index != -1) _messages.removeAt(index);
return false; // Exit loop
}
});
}
// Increase the backoff time for the next retry.
_backoffTime == 0 ? _backoffTime++ : _backoffTime *= 2;
// Increment the retry count and attempt the reconnect.
_retries++;
messageBuffer.add(
// Add directly to messages, not buffer, so it shows immediately
_messages.add(
IRCMessage.createNotice(
message: 'Reconnecting to chat (attempt $_retries)...',
message: 'Reconnecting... (attempt $_retries of $_maxRetries)',
),
);
_channelListener?.cancel();
connectToChat();
connectToChat(isReconnect: true);
},
);
@ -628,19 +786,116 @@ abstract class ChatStoreBase with Store {
messageBuffer.clear();
}
/// Cancels the chat delay countdown and removes the countdown message.
@action
void _cancelChatDelayCountdown() {
_chatDelayCountdownTimer?.cancel();
_chatDelayCountdownTimer = null;
// Remove the countdown message if it exists
if (_countdownMessage != null) {
_messages.remove(_countdownMessage);
_countdownMessage = null;
}
}
/// Starts and manages the chat delay countdown message.
@action
void _startChatDelayCountdown() {
// Cancel any existing countdown first (prevents duplicate messages)
_cancelChatDelayCountdown();
// Flush any buffered messages first to ensure countdown is at the bottom
if (messageBuffer.isNotEmpty) {
_messages.addAll(messageBuffer);
messageBuffer.clear();
}
var remainingSeconds = settings.chatDelay.toInt();
// Create and store reference to the countdown message
_countdownMessage = IRCMessage.createNotice(
message: 'Chat will sync in ${remainingSeconds}s...',
);
_messages.add(_countdownMessage!);
// Update countdown every second
_chatDelayCountdownTimer = Timer.periodic(const Duration(seconds: 1), (
timer,
) {
remainingSeconds--;
// Use runInAction for proper MobX reactivity in async callbacks
runInAction(() {
// Check if countdown message still exists in the list
// (could be removed by message limit cleanup)
if (_countdownMessage == null ||
!_messages.contains(_countdownMessage)) {
timer.cancel();
_countdownMessage = null;
_chatDelayCountdownTimer = null;
return;
}
final index = _messages.indexOf(_countdownMessage!);
if (index != -1) {
if (remainingSeconds > 0) {
// Create new message and update reference
_countdownMessage = IRCMessage.createNotice(
message: 'Chat will sync in ${remainingSeconds}s...',
);
_messages[index] = _countdownMessage!;
} else {
// Remove countdown message when done
_messages.removeAt(index);
_countdownMessage = null;
_chatDelayCountdownTimer = null;
_chatDelaySyncCompleted = true;
timer.cancel();
}
} else {
_countdownMessage = null;
_chatDelayCountdownTimer = null;
timer.cancel();
}
});
});
}
/// Sends the given string message by the logged-in user and adds it to [_messages].
@action
void sendMessage(String message) {
// Do not send if the message is blank/empty.
if (message.isEmpty) return;
// Prevent sending multiple messages simultaneously
if (_isSendingMessage) {
updateNotification('Please wait, sending previous message...');
return;
}
if (_channel == null || _channel?.closeCode != null) {
messageBuffer.add(
IRCMessage.createNotice(
message: 'Failed to send message: disconnected from chat.',
message:
'Cannot send message - chat is disconnected. Reconnecting...',
),
);
} else {
// Set sending state to true
_isSendingMessage = true;
// Start a timeout timer to reset sending state if no USERSTATE is received
_sendingTimeoutTimer?.cancel();
_sendingTimeoutTimer = Timer(const Duration(seconds: 10), () {
if (_isSendingMessage) {
_isSendingMessage = false;
updateNotification(
'Message failed to send. Try again or check connection.',
);
}
});
// Send the message to the IRC chat room.
_channel?.sink.add(
'${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message',
@ -715,16 +970,22 @@ abstract class ChatStoreBase with Store {
// when copying messages repeatedly.
_notificationTimer?.cancel();
// If empty, clear the notification and don't make a new timer (empty message means cancelling the notification).
if (notificationMessage.isEmpty) {
_notification = null;
return;
// Provide subtle haptic feedback when notification is triggered
HapticFeedback.lightImpact();
// Set the new notification message and create a new timer that will dismiss it after 3 seconds.
_notification = notificationMessage;
_notificationTimer = Timer(
const Duration(seconds: 5),
() => _notification = null,
);
}
// Set the new notification message and create a new timer that will dismiss it after 2 seconds.
_notification = notificationMessage;
_notificationTimer =
Timer(const Duration(seconds: 3), () => _notification = null);
/// Clears the current notification immediately.
@action
void clearNotification() {
_notificationTimer?.cancel();
_notification = null;
}
/// Updates the sleep timer with the given [duration].
@ -741,9 +1002,7 @@ abstract class ChatStoreBase with Store {
timeRemaining = duration;
// Set a periodic timer that will update the time remaining every second.
sleepTimer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
sleepTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
// If the timer is up, cancel the timer and exit the app.
if (timeRemaining.inSeconds == 0) {
timer.cancel();
@ -753,8 +1012,7 @@ abstract class ChatStoreBase with Store {
// Decrement the time remaining.
timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1);
},
);
});
}
/// Cancels the sleep timer and resets the time remaining.
@ -766,8 +1024,9 @@ abstract class ChatStoreBase with Store {
@action
Future<void> getRecentMessage() async {
final recentMessages =
await twitchApi.getRecentMessages(userLogin: channelName);
final recentMessages = await twitchApi.getRecentMessages(
userLogin: channelName,
);
for (final message in recentMessages) {
_handleIRCData(message);
@ -780,6 +1039,8 @@ abstract class ChatStoreBase with Store {
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
_sendingTimeoutTimer?.cancel();
_cancelChatDelayCountdown();
sleepTimer?.cancel();
// Cancel WebSocket subscriptions
@ -800,8 +1061,17 @@ abstract class ChatStoreBase with Store {
assetsStore.dispose();
chatDetailsStore.dispose();
}
// Disable wakelock so that the sleep timer will function properly.
WakelockPlus.disable();
/// Unfocuses the text field.
@action
void unfocusInput() {
textFieldFocusNode.unfocus();
}
/// Requests focus for the text field.
@action
void safeRequestFocus() {
textFieldFocusNode.requestFocus();
}
}

View file

@ -12,13 +12,16 @@ mixin _$ChatStore on ChatStoreBase, Store {
Computed<List<IRCMessage>>? _$renderMessagesComputed;
@override
List<IRCMessage> get renderMessages => (_$renderMessagesComputed ??=
Computed<List<IRCMessage>>(() => super.renderMessages,
name: 'ChatStoreBase.renderMessages'))
.value;
List<IRCMessage> get renderMessages =>
(_$renderMessagesComputed ??= Computed<List<IRCMessage>>(
() => super.renderMessages,
name: 'ChatStoreBase.renderMessages',
)).value;
late final _$timeRemainingAtom =
Atom(name: 'ChatStoreBase.timeRemaining', context: context);
late final _$timeRemainingAtom = Atom(
name: 'ChatStoreBase.timeRemaining',
context: context,
);
@override
Duration get timeRemaining {
@ -33,8 +36,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_notificationAtom =
Atom(name: 'ChatStoreBase._notification', context: context);
late final _$_notificationAtom = Atom(
name: 'ChatStoreBase._notification',
context: context,
);
String? get notification {
_$_notificationAtom.reportRead();
@ -51,8 +56,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_messagesAtom =
Atom(name: 'ChatStoreBase._messages', context: context);
late final _$_messagesAtom = Atom(
name: 'ChatStoreBase._messages',
context: context,
);
ObservableList<IRCMessage> get messages {
_$_messagesAtom.reportRead();
@ -69,8 +76,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_autoScrollAtom =
Atom(name: 'ChatStoreBase._autoScroll', context: context);
late final _$_autoScrollAtom = Atom(
name: 'ChatStoreBase._autoScroll',
context: context,
);
bool get autoScroll {
_$_autoScrollAtom.reportRead();
@ -87,8 +96,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_inputTextAtom =
Atom(name: 'ChatStoreBase._inputText', context: context);
late final _$_inputTextAtom = Atom(
name: 'ChatStoreBase._inputText',
context: context,
);
String get inputText {
_$_inputTextAtom.reportRead();
@ -105,8 +116,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_showSendButtonAtom =
Atom(name: 'ChatStoreBase._showSendButton', context: context);
late final _$_showSendButtonAtom = Atom(
name: 'ChatStoreBase._showSendButton',
context: context,
);
bool get showSendButton {
_$_showSendButtonAtom.reportRead();
@ -123,8 +136,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_showEmoteAutocompleteAtom =
Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context);
late final _$_showEmoteAutocompleteAtom = Atom(
name: 'ChatStoreBase._showEmoteAutocomplete',
context: context,
);
bool get showEmoteAutocomplete {
_$_showEmoteAutocompleteAtom.reportRead();
@ -136,14 +151,19 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
set _showEmoteAutocomplete(bool value) {
_$_showEmoteAutocompleteAtom
.reportWrite(value, super._showEmoteAutocomplete, () {
_$_showEmoteAutocompleteAtom.reportWrite(
value,
super._showEmoteAutocomplete,
() {
super._showEmoteAutocomplete = value;
});
},
);
}
late final _$_showMentionAutocompleteAtom =
Atom(name: 'ChatStoreBase._showMentionAutocomplete', context: context);
late final _$_showMentionAutocompleteAtom = Atom(
name: 'ChatStoreBase._showMentionAutocomplete',
context: context,
);
bool get showMentionAutocomplete {
_$_showMentionAutocompleteAtom.reportRead();
@ -155,14 +175,59 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
set _showMentionAutocomplete(bool value) {
_$_showMentionAutocompleteAtom
.reportWrite(value, super._showMentionAutocomplete, () {
_$_showMentionAutocompleteAtom.reportWrite(
value,
super._showMentionAutocomplete,
() {
super._showMentionAutocomplete = value;
},
);
}
late final _$_isSendingMessageAtom = Atom(
name: 'ChatStoreBase._isSendingMessage',
context: context,
);
bool get isSendingMessage {
_$_isSendingMessageAtom.reportRead();
return super._isSendingMessage;
}
@override
bool get _isSendingMessage => isSendingMessage;
@override
set _isSendingMessage(bool value) {
_$_isSendingMessageAtom.reportWrite(value, super._isSendingMessage, () {
super._isSendingMessage = value;
});
}
late final _$_userStateAtom =
Atom(name: 'ChatStoreBase._userState', context: context);
late final _$_isInSharedChatModeAtom = Atom(
name: 'ChatStoreBase._isInSharedChatMode',
context: context,
);
bool get isInSharedChatMode {
_$_isInSharedChatModeAtom.reportRead();
return super._isInSharedChatMode;
}
@override
bool get _isInSharedChatMode => isInSharedChatMode;
@override
set _isInSharedChatMode(bool value) {
_$_isInSharedChatModeAtom.reportWrite(value, super._isInSharedChatMode, () {
super._isInSharedChatMode = value;
});
}
late final _$_userStateAtom = Atom(
name: 'ChatStoreBase._userState',
context: context,
);
USERSTATE get userState {
_$_userStateAtom.reportRead();
@ -179,8 +244,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$expandChatAtom =
Atom(name: 'ChatStoreBase.expandChat', context: context);
late final _$expandChatAtom = Atom(
name: 'ChatStoreBase.expandChat',
context: context,
);
@override
bool get expandChat {
@ -195,8 +262,10 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$replyingToMessageAtom =
Atom(name: 'ChatStoreBase.replyingToMessage', context: context);
late final _$replyingToMessageAtom = Atom(
name: 'ChatStoreBase.replyingToMessage',
context: context,
);
@override
IRCMessage? get replyingToMessage {
@ -211,29 +280,60 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$getAssetsAsyncAction =
AsyncAction('ChatStoreBase.getAssets', context: context);
late final _$getAssetsAsyncAction = AsyncAction(
'ChatStoreBase.getAssets',
context: context,
);
@override
Future<void> getAssets() {
return _$getAssetsAsyncAction.run(() => super.getAssets());
}
late final _$getRecentMessageAsyncAction =
AsyncAction('ChatStoreBase.getRecentMessage', context: context);
late final _$connectToChatAsyncAction = AsyncAction(
'ChatStoreBase.connectToChat',
context: context,
);
@override
Future<void> connectToChat({bool isReconnect = false}) {
return _$connectToChatAsyncAction.run(
() => super.connectToChat(isReconnect: isReconnect),
);
}
late final _$getRecentMessageAsyncAction = AsyncAction(
'ChatStoreBase.getRecentMessage',
context: context,
);
@override
Future<void> getRecentMessage() {
return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage());
}
late final _$ChatStoreBaseActionController =
ActionController(name: 'ChatStoreBase', context: context);
late final _$ChatStoreBaseActionController = ActionController(
name: 'ChatStoreBase',
context: context,
);
@override
void revealMessage(String id) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.revealMessage',
);
try {
return super.revealMessage(id);
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _handleIRCData(String data) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase._handleIRCData');
name: 'ChatStoreBase._handleIRCData',
);
try {
return super._handleIRCData(data);
} finally {
@ -244,7 +344,8 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
void resumeScroll() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.resumeScroll');
name: 'ChatStoreBase.resumeScroll',
);
try {
return super.resumeScroll();
} finally {
@ -255,7 +356,8 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
void listenToSevenTVEmoteSet({required String emoteSetId}) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.listenToSevenTVEmoteSet');
name: 'ChatStoreBase.listenToSevenTVEmoteSet',
);
try {
return super.listenToSevenTVEmoteSet(emoteSetId: emoteSetId);
} finally {
@ -263,21 +365,11 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
}
@override
void connectToChat() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.connectToChat');
try {
return super.connectToChat();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void addMessages() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.addMessages');
name: 'ChatStoreBase.addMessages',
);
try {
return super.addMessages();
} finally {
@ -285,10 +377,35 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
}
@override
void _cancelChatDelayCountdown() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase._cancelChatDelayCountdown',
);
try {
return super._cancelChatDelayCountdown();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void _startChatDelayCountdown() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase._startChatDelayCountdown',
);
try {
return super._startChatDelayCountdown();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void sendMessage(String message) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.sendMessage');
name: 'ChatStoreBase.sendMessage',
);
try {
return super.sendMessage(message);
} finally {
@ -299,7 +416,8 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
void addEmote(Emote emote, {bool autocompleteMode = false}) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.addEmote');
name: 'ChatStoreBase.addEmote',
);
try {
return super.addEmote(emote, autocompleteMode: autocompleteMode);
} finally {
@ -310,7 +428,8 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
void updateNotification(String notificationMessage) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.updateNotification');
name: 'ChatStoreBase.updateNotification',
);
try {
return super.updateNotification(notificationMessage);
} finally {
@ -319,13 +438,30 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
@override
void updateSleepTimer(
{required Duration duration, required VoidCallback onTimerFinished}) {
void clearNotification() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.updateSleepTimer');
name: 'ChatStoreBase.clearNotification',
);
try {
return super.clearNotification();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void updateSleepTimer({
required Duration duration,
required VoidCallback onTimerFinished,
}) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.updateSleepTimer',
);
try {
return super.updateSleepTimer(
duration: duration, onTimerFinished: onTimerFinished);
duration: duration,
onTimerFinished: onTimerFinished,
);
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
@ -334,7 +470,8 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
void cancelSleepTimer() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.cancelSleepTimer');
name: 'ChatStoreBase.cancelSleepTimer',
);
try {
return super.cancelSleepTimer();
} finally {
@ -342,6 +479,30 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
}
@override
void unfocusInput() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.unfocusInput',
);
try {
return super.unfocusInput();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void safeRequestFocus() {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.safeRequestFocus',
);
try {
return super.safeRequestFocus();
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View file

@ -0,0 +1,388 @@
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:mobx/mobx.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
part 'chat_tabs_store.g.dart';
/// Lightweight data class for persisting chat tab info to settings.
/// Does NOT include ChatStore since WebSocket connections cannot be persisted.
@JsonSerializable()
class PersistedChatTab {
final String channelId;
final String channelLogin;
final String displayName;
const PersistedChatTab({
required this.channelId,
required this.channelLogin,
required this.displayName,
});
factory PersistedChatTab.fromJson(Map<String, dynamic> json) =>
_$PersistedChatTabFromJson(json);
Map<String, dynamic> toJson() => _$PersistedChatTabToJson(this);
/// Create from a ChatTabInfo (for syncing current tabs to settings).
factory PersistedChatTab.fromChatTabInfo(ChatTabInfo info) =>
PersistedChatTab(
channelId: info.channelId,
channelLogin: info.channelLogin,
displayName: info.displayName,
);
}
/// Data class holding information about a chat tab.
class ChatTabInfo {
final String channelId;
final String channelLogin;
final String displayName;
/// The ChatStore for this tab. Null until the tab is activated (lazy loading).
ChatStore? chatStore;
/// Whether this is the primary tab (first tab, cannot be removed).
final bool isPrimary;
/// Whether this tab has been activated (chatStore created).
bool get isActivated => chatStore != null;
ChatTabInfo({
required this.channelId,
required this.channelLogin,
required this.displayName,
this.chatStore,
required this.isPrimary,
});
}
/// Store that manages multiple chat tabs.
class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore;
abstract class ChatTabsStoreBase with Store {
/// Maximum number of tabs allowed.
static const maxTabs = 10;
/// API services needed for creating ChatStore instances.
final TwitchApi twitchApi;
final BTTVApi bttvApi;
final FFZApi ffzApi;
final SevenTVApi sevenTVApi;
/// Global stores passed to each ChatStore.
final AuthStore authStore;
final SettingsStore settingsStore;
final GlobalAssetsStore globalAssetsStore;
/// The list of open chat tabs.
@readonly
var _tabs = ObservableList<ChatTabInfo>();
/// The index of the currently active tab.
@observable
var activeTabIndex = 0;
/// Returns the currently active tab info.
@computed
ChatTabInfo get activeTab => _tabs[activeTabIndex];
/// Returns the ChatStore of the currently active tab.
/// The active tab is always activated, so this should never be null.
@computed
ChatStore get activeChatStore => _tabs[activeTabIndex].chatStore!;
/// Whether we can add more tabs (under the limit).
@computed
bool get canAddTab => _tabs.length < maxTabs;
/// Whether the tab bar should be visible (more than 1 tab).
@computed
bool get showTabBar => _tabs.length > 1;
/// Public getter for the tabs list.
List<ChatTabInfo> get tabs => _tabs;
ChatTabsStoreBase({
required this.twitchApi,
required this.bttvApi,
required this.ffzApi,
required this.sevenTVApi,
required this.authStore,
required this.settingsStore,
required this.globalAssetsStore,
required String primaryChannelId,
required String primaryChannelLogin,
required String primaryDisplayName,
}) {
// Enable wakelock once for all chat tabs
WakelockPlus.enable();
// Initialize with the primary channel tab
_addPrimaryTab(
channelId: primaryChannelId,
channelLogin: primaryChannelLogin,
displayName: primaryDisplayName,
);
// Restore persisted secondary tabs if feature is enabled
if (settingsStore.persistChatTabs) {
_restoreSecondaryTabs(primaryChannelId: primaryChannelId);
}
}
/// Creates a ChatStore for a given channel.
ChatStore _createChatStore({
required String channelId,
required String channelLogin,
required String displayName,
}) {
return ChatStore(
twitchApi: twitchApi,
channelName: channelLogin,
channelId: channelId,
displayName: displayName,
auth: authStore,
settings: settingsStore,
chatDetailsStore: ChatDetailsStore(
twitchApi: twitchApi,
channelName: channelLogin,
),
assetsStore: ChatAssetsStore(
twitchApi: twitchApi,
ffzApi: ffzApi,
bttvApi: bttvApi,
sevenTVApi: sevenTVApi,
globalAssetsStore: globalAssetsStore,
),
);
}
/// Activates a tab by creating its ChatStore if not already activated.
@action
void activateTab(int index) {
if (index < 0 || index >= _tabs.length) return;
final tab = _tabs[index];
if (tab.chatStore != null) return; // Already activated
tab.chatStore = _createChatStore(
channelId: tab.channelId,
channelLogin: tab.channelLogin,
displayName: tab.displayName,
);
}
/// Adds the primary tab (called during initialization).
void _addPrimaryTab({
required String channelId,
required String channelLogin,
required String displayName,
}) {
final chatStore = _createChatStore(
channelId: channelId,
channelLogin: channelLogin,
displayName: displayName,
);
_tabs.add(
ChatTabInfo(
channelId: channelId,
channelLogin: channelLogin,
displayName: displayName,
chatStore: chatStore,
isPrimary: true,
),
);
}
/// Restores secondary tabs from settings (lazy - no ChatStore created).
void _restoreSecondaryTabs({required String primaryChannelId}) {
for (final persisted in settingsStore.secondaryTabs) {
// Skip if this is the same as the primary tab (already added)
if (persisted.channelId == primaryChannelId) {
continue;
}
// Skip if at max capacity
if (_tabs.length >= maxTabs) {
break;
}
// Add tab without ChatStore (lazy loading)
_tabs.add(
ChatTabInfo(
channelId: persisted.channelId,
channelLogin: persisted.channelLogin,
displayName: persisted.displayName,
isPrimary: false,
),
);
}
}
/// Syncs current secondary tabs to settings for persistence.
void _syncSecondaryTabsToSettings() {
if (!settingsStore.persistChatTabs) return;
// Get all non-primary tabs and convert to PersistedChatTab
final secondaryTabs = _tabs
.where((tab) => !tab.isPrimary)
.map(PersistedChatTab.fromChatTabInfo)
.toList();
// Update settings (autorun will handle persistence)
settingsStore.secondaryTabs = secondaryTabs;
}
/// Adds a new chat tab for the given channel.
/// Returns true if the tab was added, false if at limit or duplicate.
@action
bool addTab({
required String channelId,
required String channelLogin,
required String displayName,
}) {
// Check if at max capacity
if (_tabs.length >= maxTabs) {
return false;
}
// Check for duplicate channel
final existingIndex = _tabs.indexWhere((tab) => tab.channelId == channelId);
if (existingIndex != -1) {
// Switch to existing tab instead of adding duplicate
setActiveTab(existingIndex);
return false;
}
// Add the new tab (lazy - no ChatStore yet)
_tabs.add(
ChatTabInfo(
channelId: channelId,
channelLogin: channelLogin,
displayName: displayName,
isPrimary: false,
),
);
// Switch to the new tab (this will activate it)
final newIndex = _tabs.length - 1;
activateTab(newIndex);
activeTabIndex = newIndex;
// Sync to settings for persistence
_syncSecondaryTabsToSettings();
return true;
}
/// Removes the tab at the given index.
/// Returns true if removed, false if primary tab or invalid index.
@action
bool removeTab(int index) {
// Validate index
if (index < 0 || index >= _tabs.length) {
return false;
}
// Cannot remove primary tab
if (_tabs[index].isPrimary) {
return false;
}
// Dispose the ChatStore before removing (if activated)
_tabs[index].chatStore?.dispose();
// Remove the tab
_tabs.removeAt(index);
// Adjust active index if necessary
if (activeTabIndex >= _tabs.length) {
activeTabIndex = _tabs.length - 1;
} else if (activeTabIndex > index) {
activeTabIndex--;
}
// Ensure the new active tab is activated (in case it was lazy)
activateTab(activeTabIndex);
// Sync to settings for persistence
_syncSecondaryTabsToSettings();
return true;
}
/// Reorders a tab from oldIndex to newIndex.
/// The primary tab (index 0) cannot be moved, and no tab can be placed before it.
@action
void reorderTab(int oldIndex, int newIndex) {
// Primary tab (index 0) cannot be moved, and nothing can move before it
if (oldIndex == 0 || newIndex == 0) return;
// ReorderableListView passes newIndex as if item was already removed
if (oldIndex < newIndex) {
newIndex -= 1;
}
if (oldIndex == newIndex) return;
final tab = _tabs.removeAt(oldIndex);
_tabs.insert(newIndex, tab);
// Adjust activeTabIndex to follow the active tab
if (activeTabIndex == oldIndex) {
activeTabIndex = newIndex;
} else if (oldIndex < activeTabIndex && newIndex >= activeTabIndex) {
activeTabIndex -= 1;
} else if (oldIndex > activeTabIndex && newIndex <= activeTabIndex) {
activeTabIndex += 1;
}
_syncSecondaryTabsToSettings();
}
/// Sets the active tab to the given index.
@action
void setActiveTab(int index) {
if (index >= 0 && index < _tabs.length) {
// Clear text input and emote menu when switching tabs
if (index != activeTabIndex) {
final currentStore = _tabs[activeTabIndex].chatStore;
if (currentStore != null) {
// Close emote menu if open
currentStore.assetsStore.showEmoteMenu = false;
// Unfocus input
currentStore.unfocusInput();
// Clear text input
currentStore.textController.clear();
// Clear reply state
currentStore.replyingToMessage = null;
}
}
// Activate the target tab if not already activated
activateTab(index);
activeTabIndex = index;
}
}
/// Disposes all ChatStores and cleans up resources.
void dispose() {
for (final tab in _tabs) {
tab.chatStore?.dispose();
}
_tabs.clear();
// Disable wakelock once after all tabs disposed
WakelockPlus.disable();
}
}

View file

@ -0,0 +1,181 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_tabs_store.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PersistedChatTab _$PersistedChatTabFromJson(Map<String, dynamic> json) =>
PersistedChatTab(
channelId: json['channelId'] as String,
channelLogin: json['channelLogin'] as String,
displayName: json['displayName'] as String,
);
Map<String, dynamic> _$PersistedChatTabToJson(PersistedChatTab instance) =>
<String, dynamic>{
'channelId': instance.channelId,
'channelLogin': instance.channelLogin,
'displayName': instance.displayName,
};
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$ChatTabsStore on ChatTabsStoreBase, Store {
Computed<ChatTabInfo>? _$activeTabComputed;
@override
ChatTabInfo get activeTab => (_$activeTabComputed ??= Computed<ChatTabInfo>(
() => super.activeTab,
name: 'ChatTabsStoreBase.activeTab',
)).value;
Computed<ChatStore>? _$activeChatStoreComputed;
@override
ChatStore get activeChatStore =>
(_$activeChatStoreComputed ??= Computed<ChatStore>(
() => super.activeChatStore,
name: 'ChatTabsStoreBase.activeChatStore',
)).value;
Computed<bool>? _$canAddTabComputed;
@override
bool get canAddTab => (_$canAddTabComputed ??= Computed<bool>(
() => super.canAddTab,
name: 'ChatTabsStoreBase.canAddTab',
)).value;
Computed<bool>? _$showTabBarComputed;
@override
bool get showTabBar => (_$showTabBarComputed ??= Computed<bool>(
() => super.showTabBar,
name: 'ChatTabsStoreBase.showTabBar',
)).value;
late final _$_tabsAtom = Atom(
name: 'ChatTabsStoreBase._tabs',
context: context,
);
ObservableList<ChatTabInfo> get tabs {
_$_tabsAtom.reportRead();
return super._tabs;
}
@override
ObservableList<ChatTabInfo> get _tabs => tabs;
@override
set _tabs(ObservableList<ChatTabInfo> value) {
_$_tabsAtom.reportWrite(value, super._tabs, () {
super._tabs = value;
});
}
late final _$activeTabIndexAtom = Atom(
name: 'ChatTabsStoreBase.activeTabIndex',
context: context,
);
@override
int get activeTabIndex {
_$activeTabIndexAtom.reportRead();
return super.activeTabIndex;
}
@override
set activeTabIndex(int value) {
_$activeTabIndexAtom.reportWrite(value, super.activeTabIndex, () {
super.activeTabIndex = value;
});
}
late final _$ChatTabsStoreBaseActionController = ActionController(
name: 'ChatTabsStoreBase',
context: context,
);
@override
void activateTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.activateTab',
);
try {
return super.activateTab(index);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
bool addTab({
required String channelId,
required String channelLogin,
required String displayName,
}) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.addTab',
);
try {
return super.addTab(
channelId: channelId,
channelLogin: channelLogin,
displayName: displayName,
);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
bool removeTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.removeTab',
);
try {
return super.removeTab(index);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void reorderTab(int oldIndex, int newIndex) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.reorderTab',
);
try {
return super.reorderTab(oldIndex, newIndex);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void setActiveTab(int index) {
final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction(
name: 'ChatTabsStoreBase.setActiveTab',
);
try {
return super.setActiveTab(index);
} finally {
_$ChatTabsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
activeTabIndex: ${activeTabIndex},
activeTab: ${activeTab},
activeChatStore: ${activeChatStore},
canAddTab: ${canAddTab},
showTabBar: ${showTabBar}
''';
}
}

View file

@ -0,0 +1,256 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/live_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:frosty/widgets/uptime.dart';
/// Data class returned when a channel is selected in the AddChatSheet.
class AddChatResult {
final String channelId;
final String channelLogin;
final String displayName;
const AddChatResult({
required this.channelId,
required this.channelLogin,
required this.displayName,
});
}
/// Bottom sheet for adding a new chat tab by searching for a Twitch channel.
class AddChatSheet extends StatefulWidget {
final TwitchApi twitchApi;
const AddChatSheet({super.key, required this.twitchApi});
/// Shows the bottom sheet and returns the selected channel info, or null if cancelled.
static Future<AddChatResult?> show(
BuildContext context,
TwitchApi twitchApi,
) {
return showModalBottomSheetWithProperFocus<AddChatResult>(
context: context,
isScrollControlled: true,
builder: (context) => AddChatSheet(twitchApi: twitchApi),
);
}
@override
State<AddChatSheet> createState() => _AddChatSheetState();
}
class _AddChatSheetState extends State<AddChatSheet> {
final _textController = TextEditingController();
final _focusNode = FocusNode();
final _scrollController = ScrollController();
Timer? _debounce;
bool _isLoading = false;
String? _errorMessage;
List<ChannelQuery> _results = [];
@override
void initState() {
super.initState();
_textController.addListener(() => setState(() {}));
_focusNode.addListener(() => setState(() {}));
}
@override
void dispose() {
_debounce?.cancel();
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
// Cancel any existing debounce
_debounce?.cancel();
if (query.isEmpty) {
setState(() {
_results = [];
_errorMessage = null;
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
// Debounce the search by 300ms
_debounce = Timer(const Duration(milliseconds: 300), () async {
try {
final channels = await widget.twitchApi.searchChannels(query: query);
if (mounted) {
setState(() {
_results = channels;
_isLoading = false;
_errorMessage = null;
});
}
} catch (e) {
if (mounted) {
setState(() {
_results = [];
_isLoading = false;
_errorMessage = 'Failed to search channels';
});
}
}
});
}
void _selectChannel(ChannelQuery channel) {
Navigator.of(context).pop(
AddChatResult(
channelId: channel.id,
channelLogin: channel.broadcasterLogin,
displayName: channel.displayName,
),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader('Add Chat', isFirst: true),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: TextField(
controller: _textController,
focusNode: _focusNode,
autocorrect: false,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Search for a channel',
suffixIcon:
_focusNode.hasFocus || _textController.text.isNotEmpty
? IconButton(
tooltip: _textController.text.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (_textController.text.isEmpty) {
_focusNode.unfocus();
}
_textController.clear();
_onSearchChanged('');
},
icon: const Icon(Icons.close_rounded),
)
: null,
),
onChanged: _onSearchChanged,
textInputAction: TextInputAction.search,
),
),
AnimatedScrollBorder(scrollController: _scrollController),
Expanded(child: _buildResults()),
],
),
),
);
}
Widget _buildResults() {
if (_textController.text.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Search for a channel to add',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
if (_isLoading) {
return ListView.builder(
controller: _scrollController,
itemCount: 8,
itemBuilder: (context, index) => ChannelSkeletonLoader(index: index),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: AlertMessage(message: _errorMessage!),
),
);
}
if (_results.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No channels found',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return ListView.builder(
controller: _scrollController,
padding: EdgeInsets.zero,
itemCount: _results.length,
itemBuilder: (context, index) {
final channel = _results[index];
final displayName = getReadableName(
channel.displayName,
channel.broadcasterLogin,
);
return ListTile(
leading: ProfilePicture(
userLogin: channel.broadcasterLogin,
radius: 16,
),
title: Text(displayName),
subtitle: channel.isLive
? Row(
spacing: 6,
children: [
const LiveIndicator(),
Uptime(startTime: channel.startedAt),
],
)
: null,
onTap: () => _selectChannel(channel),
);
},
);
}
}
// Keep backward compatibility alias
typedef AddChatDialog = AddChatSheet;

View file

@ -5,17 +5,28 @@ import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore;
const ChatBottomBar({super.key, required this.chatStore});
/// Callback to add a new chat tab.
/// Passes this to ChatDetails to show "Add chat" option.
final VoidCallback onAddChat;
const ChatBottomBar({
super.key,
required this.chatStore,
required this.onAddChat,
});
@override
Widget build(BuildContext context) {
final isEmotesEnabled = chatStore.settings.showTwitchEmotes ||
final isEmotesEnabled =
chatStore.settings.showTwitchEmotes ||
chatStore.settings.show7TVEmotes ||
chatStore.settings.showBTTVEmotes ||
chatStore.settings.showFFZEmotes;
@ -34,7 +45,7 @@ class ChatBottomBar extends StatelessWidget {
: Icons.emoji_emotions_outlined,
),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.unfocusInput();
chatStore.assetsStore.showEmoteMenu =
!chatStore.assetsStore.showEmoteMenu;
},
@ -44,7 +55,8 @@ class ChatBottomBar extends StatelessWidget {
return Observer(
builder: (context) {
final matchingEmotes = [
final matchingEmotes =
[
...chatStore.assetsStore.userEmoteToObject.values,
...chatStore.assetsStore.bttvEmotes,
...chatStore.assetsStore.ffzEmotes,
@ -69,9 +81,17 @@ class ChatBottomBar extends StatelessWidget {
)
.toList();
return Column(
final isFullscreenOverlay =
chatStore.settings.fullScreen && context.isLandscape;
final hasChatDelay =
chatStore.settings.showVideo && chatStore.settings.chatDelay > 0;
final delayTooltipMessage =
'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)';
const loginTooltipMessage = 'Log in to chat';
final bottomBarContent = Column(
children: [
AnimatedScrollBorder(scrollController: chatStore.scrollController),
if (chatStore.replyingToMessage != null) ...[
const Divider(),
ListTile(
@ -87,16 +107,16 @@ class ChatBottomBar extends StatelessWidget {
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useReadableColors: chatStore.settings.useReadableColors,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
currentChannelId: chatStore.channelId,
),
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
style: context.defaultTextStyle.copyWith(
fontSize: chatStore.settings.fontSize,
),
),
),
trailing: IconButton(
@ -138,7 +158,8 @@ class ChatBottomBar extends StatelessWidget {
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes[index].url,
useFade: false,
height: matchingEmotes[index].height?.toDouble() ??
height:
matchingEmotes[index].height?.toDouble() ??
defaultEmoteSize,
width: matchingEmotes[index].width?.toDouble(),
),
@ -187,35 +208,83 @@ class ChatBottomBar extends StatelessWidget {
if (!chatStore.expandChat &&
chatStore.settings.chatWidth < 0.3 &&
chatStore.settings.showVideo &&
MediaQuery.of(context).orientation ==
Orientation.landscape)
IconButton(
context.isLandscape)
Builder(
builder: (context) {
final isDisabled =
!chatStore.auth.isLoggedIn || hasChatDelay;
return GestureDetector(
onTap: isDisabled
? () {
final message = hasChatDelay
? delayTooltipMessage
: loginTooltipMessage;
chatStore.updateNotification(message);
}
: null,
child: IconButton(
tooltip: 'Enter a message',
onPressed: () {
onPressed: isDisabled
? null
: () {
chatStore.expandChat = true;
chatStore.textFieldFocusNode.requestFocus();
chatStore.safeRequestFocus();
},
icon: const Icon(Icons.edit),
),
);
},
)
else
Expanded(
child: Observer(
builder: (context) {
final isDisabled =
!chatStore.auth.isLoggedIn ||
chatStore.isSendingMessage ||
hasChatDelay;
return GestureDetector(
onTap: isDisabled && !chatStore.isSendingMessage
? () {
final message = hasChatDelay
? delayTooltipMessage
: loginTooltipMessage;
chatStore.updateNotification(message);
}
: null,
child: TextField(
textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 3,
enabled: chatStore.auth.isLoggedIn ? true : false,
// Disable text field when sending message, when not logged in, or when chat delay is active
enabled: !isDisabled,
decoration: InputDecoration(
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft
prefixIcon:
chatStore.settings.emoteMenuButtonOnLeft
? emoteMenuButton
: null,
suffixIcon: chatStore.settings.emoteMenuButtonOnLeft
? null
: emoteMenuButton,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (!chatStore
.settings
.emoteMenuButtonOnLeft &&
emoteMenuButton != null)
emoteMenuButton,
],
),
hintMaxLines: 1,
hintText: chatStore.auth.isLoggedIn
? 'Send a ${chatStore.replyingToMessage != null ? 'reply' : 'message'} ${chatStore.settings.chatDelay == 0 || !chatStore.settings.showVideo ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}'
: 'Log in to chat',
? chatStore.isSendingMessage
? 'Sending...'
: chatStore.replyingToMessage != null
? 'Reply'
: 'Chat'
: loginTooltipMessage,
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
@ -223,31 +292,52 @@ class ChatBottomBar extends StatelessWidget {
chatStore.textFieldFocusNode.unfocus();
},
),
);
},
),
),
if (chatStore.showSendButton &&
(chatStore.settings.chatWidth >= 0.3 ||
chatStore.expandChat ||
MediaQuery.of(context).orientation ==
Orientation.portrait))
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send_rounded),
onPressed: chatStore.auth.isLoggedIn
? () => chatStore
.sendMessage(chatStore.textController.text)
context.isPortrait))
Observer(
builder: (context) {
return IconButton(
tooltip: chatStore.isSendingMessage
? 'Sending...'
: 'Send',
icon: chatStore.isSendingMessage
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.send_rounded),
onPressed:
chatStore.auth.isLoggedIn &&
!chatStore.isSendingMessage &&
!hasChatDelay
? () => chatStore.sendMessage(
chatStore.textController.text,
)
: null,
);
},
)
else
IconButton(
icon: Icon(Icons.adaptive.more_rounded),
tooltip: 'More',
onPressed: () => showModalBottomSheet(
onPressed: () => showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (_) => ChatDetails(
chatDetailsStore: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: chatStore.channelName,
onAddChat: onAddChat,
),
),
),
@ -256,6 +346,25 @@ class ChatBottomBar extends StatelessWidget {
),
],
);
return isFullscreenOverlay
? Padding(
padding: EdgeInsets.only(
bottom: chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(context).padding.bottom,
),
child: bottomBarContent,
)
: BlurredContainer(
gradientDirection: GradientDirection.down,
padding: EdgeInsets.only(
bottom: chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(context).padding.bottom,
),
child: bottomBarContent,
);
},
);
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -7,7 +8,7 @@ import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget {
@ -15,6 +16,7 @@ class ChatMessage extends StatelessWidget {
final ChatStore chatStore;
final bool isModal;
final bool showReplyHeader;
final bool isInReplyThread;
const ChatMessage({
super.key,
@ -22,6 +24,7 @@ class ChatMessage extends StatelessWidget {
required this.chatStore,
this.isModal = false,
this.showReplyHeader = true,
this.isInReplyThread = false,
});
void onTapName(BuildContext context) {
@ -34,7 +37,7 @@ class ChatMessage extends StatelessWidget {
return;
}
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
@ -46,23 +49,14 @@ class ChatMessage extends StatelessWidget {
);
}
void onTapPingedUser(
BuildContext context, {
required String nickname,
}) {
void onTapPingedUser(BuildContext context, {required String nickname}) {
// Ignore if the message is a recent message in the modal bottom sheet.
if (isModal) return;
final twitchApi = context.read<TwitchApi>();
final authStore = context.read<AuthStore>();
twitchApi
.getUser(
headers: authStore.headersTwitch,
userLogin: nickname,
)
.then((user) {
twitchApi.getUser(userLogin: nickname).then((user) {
if (context.mounted) {
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
@ -91,31 +85,33 @@ class ChatMessage extends StatelessWidget {
return;
}
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
isScrollControlled: true,
builder: (context) => ListView(
shrinkWrap: true,
primary: false,
children: [
ListTile(
title: Text.rich(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useReadableColors: chatStore.settings.useReadableColors,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
style: defaultTextStyle,
),
),
),
const Divider(indent: 12, endIndent: 12),
ListTile(
onTap: () {
copyMessage();
@ -124,10 +120,38 @@ class ChatMessage extends StatelessWidget {
leading: const Icon(Icons.copy),
title: const Text('Copy message'),
),
ListTile(
onTap: () async {
await copyMessage();
final hasChatDelay =
chatStore.settings.showVideo &&
chatStore.settings.chatDelay > 0;
if (hasChatDelay) {
chatStore.updateNotification(
'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)',
);
if (context.mounted) {
Navigator.pop(context);
}
return;
}
// Paste the copied message into the text controller
chatStore.textController.text = ircMessage.message ?? '';
chatStore.safeRequestFocus();
if (context.mounted) {
Navigator.pop(context);
}
},
leading: const Icon(Icons.content_paste),
title: const Text('Copy message and paste'),
),
ListTile(
onTap: () {
chatStore.replyingToMessage = ircMessage;
chatStore.textFieldFocusNode.requestFocus();
chatStore.safeRequestFocus();
Navigator.pop(context);
},
leading: const Icon(Icons.reply),
@ -143,8 +167,9 @@ class ChatMessage extends StatelessWidget {
final defaultTextStyle = DefaultTextStyle.of(context).style;
final messageHeaderIconSize =
defaultBadgeSize * chatStore.settings.badgeScale;
final messageHeaderTextColor =
defaultTextStyle.color?.withValues(alpha: 0.5);
final messageHeaderTextColor = defaultTextStyle.color?.withValues(
alpha: 0.5,
);
const messageHeaderFontWeight = FontWeight.w600;
return Observer(
@ -158,7 +183,8 @@ class ChatMessage extends StatelessWidget {
final shouldHighlightFirstMessage =
chatStore.settings.highlightFirstTimeChatter &&
ircMessage.tags['first-msg'] == '1';
final shouldHighlightMessage = chatStore.settings.showUserNotices &&
final shouldHighlightMessage =
chatStore.settings.showUserNotices &&
ircMessage.tags['msg-id'] == 'highlighted-message';
final messageSpan = Text.rich(
@ -172,11 +198,11 @@ class ChatMessage extends StatelessWidget {
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useReadableColors: chatStore.settings.useReadableColors,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
),
);
@ -196,7 +222,7 @@ class ChatMessage extends StatelessWidget {
messageHeader = GestureDetector(
onTap: isModal
? null
: () => showModalBottomSheet(
: () => showModalBottomSheetWithProperFocus(
context: context,
builder: (context) {
return ReplyThread(
@ -205,14 +231,25 @@ class ChatMessage extends StatelessWidget {
);
},
),
child: Text(
'Replying to @$replyUser: $replyBody',
child: Text.rich(
maxLines: 1,
style: TextStyle(
overflow: TextOverflow.ellipsis,
TextSpan(
children: [
TextSpan(
text: replyUser,
style: TextStyle(
fontWeight: FontWeight.w600,
color: messageHeaderTextColor,
),
),
TextSpan(
text: ': $replyBody',
style: TextStyle(color: messageHeaderTextColor),
),
],
),
),
);
} else if (shouldHighlightFirstMessage) {
highlightColor = Colors.purple;
@ -245,20 +282,18 @@ class ChatMessage extends StatelessWidget {
if (messageHeader != null) {
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (messageHeaderIcon != null)
Row(
spacing: 4,
children: [
messageHeaderIcon,
const SizedBox(width: 4),
Flexible(
child: messageHeader,
),
Flexible(child: messageHeader),
],
)
else
messageHeader,
const SizedBox(height: 4),
messageSpan,
],
);
@ -276,6 +311,7 @@ class ChatMessage extends StatelessWidget {
opacity: 0.5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (banDuration == null)
if (ircMessage.command == Command.clearMessage)
@ -291,10 +327,10 @@ class ChatMessage extends StatelessWidget {
else
Text(
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}',
style:
const TextStyle(fontWeight: messageHeaderFontWeight),
style: const TextStyle(
fontWeight: messageHeaderFontWeight,
),
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
@ -304,12 +340,21 @@ class ChatMessage extends StatelessWidget {
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
showMessage: chatStore.settings.showDeletedMessages,
useReadableColors: chatStore.settings.useReadableColors,
showMessage:
chatStore.settings.showDeletedMessages ||
chatStore.revealedMessageIds.contains(
ircMessage.tags['id'],
),
onTapDeletedMessage: () {
if (ircMessage.tags['id'] != null) {
chatStore.revealMessage(ircMessage.tags['id']!);
}
},
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
),
),
@ -318,10 +363,36 @@ class ChatMessage extends StatelessWidget {
);
break;
case Command.notice:
if (ircMessage.actionCallback != null &&
ircMessage.actionLabel != null) {
renderMessage = Text.rich(
TextSpan(
children: [
TextSpan(
text: ircMessage.message ?? '',
style: TextStyle(color: messageHeaderTextColor),
),
TextSpan(text: ' '),
TextSpan(
text: ircMessage.actionLabel!,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = ircMessage.actionCallback,
),
],
),
);
} else {
renderMessage = Text.rich(
TextSpan(text: ircMessage.message),
style: TextStyle(color: messageHeaderTextColor),
);
}
break;
case Command.userNotice:
if (chatStore.settings.showUserNotices) {
@ -334,8 +405,8 @@ class ChatMessage extends StatelessWidget {
final isGift = messageId?.contains('gift') == true;
final isSub = messageId?.contains('sub') == true;
final isRaid = messageId?.contains('raid') == true;
// TODO: Implement Prime sub icons when a crown icon is added.
// final isPrime = ircMessage.tags['msg-param-sub-plan'] == 'Prime';
final isPrime =
ircMessage.tags['msg-param-sub-plan'] == 'Prime';
IconData? icon;
if (isGift) {
@ -344,6 +415,8 @@ class ChatMessage extends StatelessWidget {
icon = Icons.star_rounded;
} else if (isRaid) {
icon = Icons.people_rounded;
} else if (isPrime) {
icon = Icons.workspace_premium_rounded;
}
if (icon != null) {
@ -374,22 +447,18 @@ class ChatMessage extends StatelessWidget {
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (messageHeader != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (messageHeaderIcon != null) ...[
messageHeaderIcon,
const SizedBox(width: 4),
],
Expanded(
child: messageHeader,
),
if (messageHeaderIcon != null) ...[messageHeaderIcon],
Expanded(child: messageHeader),
],
),
if (ircMessage.message != null) ...[
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
@ -399,12 +468,11 @@ class ChatMessage extends StatelessWidget {
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useReadableColors:
chatStore.settings.useReadableColors,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
channelIdToUserTwitch:
chatStore.assetsStore.channelIdToUserTwitch,
currentChannelId: chatStore.channelId,
),
),
),
@ -419,22 +487,45 @@ class ChatMessage extends StatelessWidget {
renderMessage = const SizedBox();
}
final paddedMessage = Padding(
padding: EdgeInsets.symmetric(
vertical: chatStore.settings.messageSpacing / 2,
horizontal: highlightColor == null ? 12 : 0,
// Check if this is a reply message in reply thread context for indentation
final isReplyInThread =
isInReplyThread &&
ircMessage.tags['reply-parent-display-name'] != null &&
ircMessage.tags['reply-parent-msg-body'] != null;
// Add reply icon for messages in reply thread
final messageWithIcon = isReplyInThread
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0, top: 2.0),
child: Icon(
Icons.subdirectory_arrow_right_rounded,
size: 16,
color: messageHeaderTextColor,
),
child: renderMessage,
),
Expanded(child: renderMessage),
],
)
: renderMessage;
final paddedMessage = Padding(
padding: EdgeInsets.only(
top: chatStore.settings.messageSpacing / 2,
bottom: chatStore.settings.messageSpacing / 2,
left: isReplyInThread ? 16 : (highlightColor == null ? 12 : 0),
right: highlightColor == null ? 12 : 0,
),
child: messageWithIcon,
);
// Add a divider above the message if dividers are enabled.
final dividedMessage = chatStore.settings.showChatMessageDividers
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
paddedMessage,
const Divider(),
],
children: [paddedMessage, const Divider()],
)
: paddedMessage;

View file

@ -0,0 +1,216 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/profile_picture.dart';
/// Widget that displays multiple chat tabs with a tab bar.
/// Wraps the existing Chat widget and manages tab switching.
class ChatTabs extends StatelessWidget {
final ChatTabsStore chatTabsStore;
final EdgeInsetsGeometry? listPadding;
const ChatTabs({super.key, required this.chatTabsStore, this.listPadding});
Future<void> _handleAddChat(BuildContext context) async {
if (!chatTabsStore.canAddTab) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Maximum 10 chats open'),
duration: Duration(seconds: 2),
),
);
return;
}
final result = await AddChatSheet.show(context, chatTabsStore.twitchApi);
if (result != null) {
final added = chatTabsStore.addTab(
channelId: result.channelId,
channelLogin: result.channelLogin,
displayName: result.displayName,
);
if (!added && context.mounted) {
// If not added, it means the channel already exists (switched to it)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Channel already open, switched to it'),
duration: Duration(seconds: 2),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
final tabs = chatTabsStore.tabs;
final activeIndex = chatTabsStore.activeTabIndex;
final showTabBar = chatTabsStore.showTabBar;
// Calculate extra top padding for tab bar when visible
final tabBarHeight = showTabBar ? 48.0 : 0.0;
// Get the top inset from listPadding (e.g., for AppBar in chat-only mode)
final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0;
// Adjust list padding to account for tab bar
final adjustedPadding = listPadding != null
? listPadding!.add(EdgeInsets.only(top: tabBarHeight))
: EdgeInsets.only(top: tabBarHeight);
return PopScope(
canPop: Platform.isIOS,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// If pressing the back button on Android while the emote menu
// is open, close it instead of going back to the streams list.
final activeStore = chatTabsStore.activeChatStore;
if (activeStore.assetsStore.showEmoteMenu) {
activeStore.assetsStore.showEmoteMenu = false;
} else {
Navigator.of(context).pop();
}
},
child: Stack(
children: [
// Chat content with IndexedStack to preserve state
Positioned.fill(
child: IndexedStack(
index: activeIndex,
children: tabs.map((tabInfo) {
// Show placeholder for non-activated tabs
if (tabInfo.chatStore == null) {
return const SizedBox.shrink();
}
return Chat(
key: ValueKey(tabInfo.channelId),
chatStore: tabInfo.chatStore!,
listPadding: adjustedPadding,
onAddChat: () => _handleAddChat(context),
);
}).toList(),
),
),
// Tab bar (only visible when more than 1 tab)
if (showTabBar)
Positioned(
top: topInset,
left: 0,
right: 0,
child: SizedBox(
height: 48,
child: ReorderableListView.builder(
scrollDirection: Axis.horizontal,
itemCount: tabs.length,
padding: const EdgeInsets.symmetric(horizontal: 12),
onReorder: (oldIndex, newIndex) {
HapticFeedback.lightImpact();
chatTabsStore.reorderTab(oldIndex, newIndex);
},
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
child: child,
);
},
itemBuilder: (context, index) {
final tabInfo = chatTabsStore.tabs[index];
return Padding(
key: ValueKey(tabInfo.channelId),
padding: EdgeInsets.only(
right: index < tabs.length - 1 ? 4 : 0,
),
child: _buildTab(context, index),
);
},
),
),
),
],
),
);
},
);
}
Future<void> _confirmRemoveTab(
BuildContext context,
int index,
String displayName,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => FrostyDialog(
title: 'Remove $displayName',
message: "Are you sure you want to remove $displayName's chat tab?",
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
if (confirmed == true) {
HapticFeedback.lightImpact();
chatTabsStore.removeTab(index);
}
}
Widget _buildTab(BuildContext context, int index) {
final tabInfo = chatTabsStore.tabs[index];
final isActive = index == chatTabsStore.activeTabIndex;
final isActivated = tabInfo.isActivated;
final displayName = getReadableName(
tabInfo.displayName,
tabInfo.channelLogin,
);
final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12);
return InputChip(
avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar),
label: Text(
displayName,
style: isActivated
? null
: TextStyle(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withValues(alpha: 0.5),
),
),
selected: isActive,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
if (!isActive) {
HapticFeedback.selectionClick();
chatTabsStore.setActiveTab(index);
}
},
onDeleted: tabInfo.isPrimary
? null
: () => _confirmRemoveTab(context, index, displayName),
deleteButtonTooltipMessage: 'Close chat',
);
}
}

View file

@ -3,7 +3,9 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
@ -36,10 +38,8 @@ class _ChatUserModalState extends State<ChatUserModal> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: const EdgeInsets.all(12),
leading: ProfilePicture(
userLogin: widget.username,
),
contentPadding: const EdgeInsets.fromLTRB(12, 0, 12, 0),
leading: ProfilePicture(userLogin: widget.username),
title: Row(
children: [
Flexible(
@ -64,13 +64,13 @@ class _ChatUserModalState extends State<ChatUserModal> {
widget.chatStore.textController.text =
'@${widget.username} ';
Navigator.pop(context);
widget.chatStore.textFieldFocusNode.requestFocus();
widget.chatStore.safeRequestFocus();
},
icon: const Icon(Icons.reply_rounded),
),
IconButton(
tooltip: 'More',
onPressed: () => showModalBottomSheet(
onPressed: () => showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => UserActionsModal(
authStore: widget.chatStore.auth,
@ -84,7 +84,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
],
),
),
const Divider(),
const Divider(indent: 12, endIndent: 12),
Expanded(
child: Observer(
builder: (context) {
@ -103,9 +103,10 @@ class _ChatUserModalState extends State<ChatUserModal> {
),
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: widget.chatStore.settings.fontSize),
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: widget.chatStore.settings.fontSize,
),
child: FrostyScrollbar(
child: ListView.builder(
reverse: true,
primary: false,
@ -117,6 +118,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
),
),
),
),
);
},
),

View file

@ -5,6 +5,7 @@ import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
class ReplyThread extends StatelessWidget {
@ -41,9 +42,9 @@ class ReplyThread extends StatelessWidget {
textScaler: TextScaler.linear(chatStore.settings.messageScale),
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
style: DefaultTextStyle.of(
context,
).style.copyWith(fontSize: chatStore.settings.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@ -54,22 +55,17 @@ class ReplyThread extends StatelessWidget {
padding: EdgeInsets.fromLTRB(12, 0, 12, 8),
),
if (replyParent != null)
ChatMessage(
ircMessage: replyParent,
chatStore: chatStore,
)
ChatMessage(ircMessage: replyParent, chatStore: chatStore)
else
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text(
'$replyName: $replyBody',
style: const TextStyle(
fontWeight: FontWeight.w500,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
),
const Divider(),
Flexible(
child: FrostyScrollbar(
child: ListView(
primary: false,
children: chatStore.messages
@ -82,6 +78,7 @@ class ReplyThread extends StatelessWidget {
(message) => ChatMessage(
isModal: true,
showReplyHeader: false,
isInReplyThread: true,
ircMessage: message,
chatStore: chatStore,
),
@ -89,6 +86,7 @@ class ReplyThread extends StatelessWidget {
.toList(),
),
),
),
],
),
),

View file

@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/live_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
class StreamInfoBar extends StatelessWidget {
final StreamTwitch? streamInfo;
final Channel? offlineChannelInfo;
final bool showCategory;
final bool tappableCategory;
final bool showUptime;
final bool showViewerCount;
final bool showOfflineIndicator;
final EdgeInsets padding;
final TooltipTriggerMode tooltipTriggerMode;
final Color? textColor;
final bool isCompact;
final bool isInSharedChatMode;
final bool isOffline;
final bool showTextShadows;
final String? displayName;
const StreamInfoBar({
super.key,
this.streamInfo,
this.offlineChannelInfo,
this.showCategory = true,
this.tappableCategory = true,
this.showUptime = true,
this.showViewerCount = true,
this.showOfflineIndicator = true,
this.padding = EdgeInsets.zero,
this.tooltipTriggerMode = TooltipTriggerMode.tap,
this.textColor,
this.isCompact = false,
this.isInSharedChatMode = false,
this.isOffline = false,
this.showTextShadows = true,
this.displayName,
});
static const _iconShadow = [
Shadow(
offset: Offset(0, 1),
blurRadius: 4,
color: Color.fromRGBO(0, 0, 0, 0.3),
),
];
static const _textShadow = [
Shadow(
offset: Offset(0, 1),
blurRadius: 4,
color: Color.fromRGBO(0, 0, 0, 0.3),
),
];
TextStyle _getBaseTextStyle(
BuildContext context,
double fontSize,
FontWeight fontWeight,
) {
return context.textTheme.bodyMedium?.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: textColor,
shadows: showTextShadows ? _textShadow : null,
) ??
const TextStyle();
}
TextStyle _getSecondaryTextStyle(BuildContext context, double fontSize) {
return context.textTheme.bodyMedium?.copyWith(
fontSize: fontSize,
fontWeight: FontWeight.w500,
color:
textColor?.withValues(alpha: 0.7) ??
context.bodySmallColor?.withValues(alpha: 0.7),
shadows: showTextShadows ? _textShadow : null,
) ??
const TextStyle();
}
@override
Widget build(BuildContext context) {
final streamTitle = isOffline
? (offlineChannelInfo?.title.trim() ?? '')
: (streamInfo?.title.trim() ?? '');
final streamerName = isOffline
? getReadableName(
offlineChannelInfo?.broadcasterName.isNotEmpty == true
? offlineChannelInfo?.broadcasterName ?? ''
: displayName ?? '',
offlineChannelInfo?.broadcasterLogin ?? '',
)
: getReadableName(
streamInfo?.userName ?? '',
streamInfo?.userLogin ?? '',
);
final secondLineSize = isCompact ? 13.0 : 14.0;
return Padding(
padding: padding,
child: Row(
spacing: 8,
children: [
Container(
decoration: isInSharedChatMode
? BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
)
: null,
child: Padding(
padding: isInSharedChatMode
? const EdgeInsets.all(1.5)
: EdgeInsets.zero,
child: ProfilePicture(
userLogin: isOffline
? (offlineChannelInfo?.broadcasterLogin.isNotEmpty == true
? offlineChannelInfo?.broadcasterLogin ?? ''
: displayName ?? '')
: (streamInfo?.userLogin ?? ''),
radius: 16,
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Top row: Streamer name + stream title
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
spacing: 4,
children: [
Tooltip(
message: streamerName,
triggerMode: tooltipTriggerMode,
child: Text(
streamerName,
style: _getBaseTextStyle(context, 14, FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
if (streamTitle.isNotEmpty) ...[
Flexible(
child: Tooltip(
message: streamTitle,
triggerMode: tooltipTriggerMode,
child: Text(
streamTitle,
style: _getSecondaryTextStyle(context, 14),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
],
],
),
// Bottom row: Live indicator, uptime, viewer count, game name or Offline text
if (isOffline ||
(!isOffline && showUptime) ||
(!isOffline && showViewerCount) ||
(showCategory &&
(isOffline
? (offlineChannelInfo?.gameName.isNotEmpty ?? false)
: (streamInfo?.gameName.isNotEmpty ?? false)))) ...[
Row(
children: [
if (isOffline && showOfflineIndicator) ...[
Text(
'Offline',
style: _getSecondaryTextStyle(
context,
secondLineSize,
),
),
],
if (isOffline &&
showCategory &&
(offlineChannelInfo?.gameName.isNotEmpty ??
false)) ...[
if (showOfflineIndicator) const SizedBox(width: 8),
Icon(
Icons.gamepad,
size: secondLineSize,
color: (textColor ?? context.bodySmallColor)
?.withValues(alpha: 0.7),
shadows: _iconShadow,
),
const SizedBox(width: 4),
Flexible(
child: Tooltip(
message: offlineChannelInfo?.gameName ?? '',
triggerMode: tooltipTriggerMode,
child: tappableCategory
? GestureDetector(
onDoubleTap: () {
if (offlineChannelInfo
?.gameId
.isNotEmpty ??
false) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
CategoryStreams(
categoryId:
offlineChannelInfo
?.gameId ??
'',
),
),
);
}
},
child: Text(
offlineChannelInfo?.gameName ?? '',
style: _getSecondaryTextStyle(
context,
secondLineSize,
),
overflow: TextOverflow.ellipsis,
),
)
: Text(
offlineChannelInfo?.gameName ?? '',
style: _getSecondaryTextStyle(
context,
secondLineSize,
),
overflow: TextOverflow.ellipsis,
),
),
),
] else ...[
if (!isOffline && (showUptime || showViewerCount)) ...[
const LiveIndicator(),
const SizedBox(width: 6),
],
if (!isOffline && showUptime) ...[
Uptime(
startTime:
streamInfo?.startedAt ??
DateTime.now().toIso8601String(),
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
),
if (!isOffline && showViewerCount)
const SizedBox(width: 8),
],
if (!isOffline && showViewerCount) ...[
Icon(
Icons.visibility,
size: secondLineSize,
color: textColor ?? context.bodySmallColor,
shadows: _iconShadow,
),
const SizedBox(width: 4),
Text(
NumberFormat().format(streamInfo?.viewerCount ?? 0),
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
),
],
if (!isOffline &&
showCategory &&
(streamInfo?.gameName.isNotEmpty ?? false) &&
(showUptime || showViewerCount)) ...[
const SizedBox(width: 8),
],
if (!isOffline &&
showCategory &&
(streamInfo?.gameName.isNotEmpty ?? false)) ...[
Icon(
Icons.gamepad,
size: secondLineSize,
color: textColor ?? context.bodySmallColor,
shadows: _iconShadow,
),
const SizedBox(width: 4),
Flexible(
child: Tooltip(
message: streamInfo?.gameName ?? '',
triggerMode: tooltipTriggerMode,
child: tappableCategory
? GestureDetector(
onDoubleTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryId:
streamInfo?.gameId ?? '',
),
),
),
child: Text(
streamInfo?.gameName ?? '',
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
)
: Text(
streamInfo?.gameName ?? '',
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
],
],
),
],
],
),
),
],
),
);
}
}

View file

@ -10,10 +10,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart';
class Video extends StatefulWidget {
final VideoStore videoStore;
const Video({
super.key,
required this.videoStore,
});
const Video({super.key, required this.videoStore});
@override
State<Video> createState() => _VideoState();
@ -24,8 +21,9 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
void initState() {
super.initState();
if (widget.videoStore.settingsStore.showVideo) {
widget.videoStore.videoWebViewController
.loadRequest(Uri.parse(widget.videoStore.videoUrl));
widget.videoStore.videoWebViewController.loadRequest(
Uri.parse(widget.videoStore.videoUrl),
);
}
WidgetsBinding.instance.addObserver(this);
}
@ -62,8 +60,9 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
widget.videoStore.videoWebViewController
.loadRequest(Uri.parse('about:blank'));
widget.videoStore.videoWebViewController.loadRequest(
Uri.parse('about:blank'),
);
super.dispose();
}

View file

@ -1,119 +0,0 @@
import 'package:flutter/material.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/profile_picture.dart';
class VideoBar extends StatelessWidget {
final StreamTwitch streamInfo;
final bool showCategory;
final bool tappableCategory;
final Color? titleTextColor;
final Color? subtitleTextColor;
final FontWeight? subtitleTextWeight;
final EdgeInsets padding;
const VideoBar({
super.key,
required this.streamInfo,
this.showCategory = true,
this.tappableCategory = true,
this.titleTextColor,
this.subtitleTextColor,
this.subtitleTextWeight,
this.padding = const EdgeInsets.all(12),
});
@override
Widget build(BuildContext context) {
final streamTitle = streamInfo.title.trim();
final category =
streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category';
final streamerName =
getReadableName(streamInfo.userName, streamInfo.userLogin);
return Padding(
padding: padding,
child: Row(
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 28,
),
const SizedBox(width: 12),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Streamer: $streamerName',
showDuration: const Duration(seconds: 3),
child: Text(
streamerName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: titleTextColor,
),
),
),
const SizedBox(height: 2),
Tooltip(
message: 'Title: ${streamInfo.title.trim()}',
showDuration: const Duration(seconds: 5),
child: Text(
streamTitle,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: subtitleTextColor ??
Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.8),
fontWeight: subtitleTextWeight,
),
),
),
if (showCategory) ...[
const SizedBox(height: 2),
InkWell(
onTap: tappableCategory && streamInfo.gameName.isNotEmpty
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryId: streamInfo.gameId,
),
),
)
: null,
child: Tooltip(
message:
'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
showDuration: const Duration(seconds: 3),
child: Text(
category,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: subtitleTextColor ??
Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.8),
fontWeight: subtitleTextWeight,
),
),
),
),
],
],
),
),
],
),
);
}
}

View file

@ -5,11 +5,14 @@ import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/live_indicator.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
@ -29,18 +32,36 @@ class VideoOverlay extends StatelessWidget {
required this.settingsStore,
});
static const _iconShadow = [
Shadow(
offset: Offset(0, 1),
blurRadius: 4,
color: Color.fromRGBO(0, 0, 0, 0.3),
),
];
static const _textShadow = [
Shadow(
offset: Offset(0, 1),
blurRadius: 4,
color: Color.fromRGBO(0, 0, 0, 0.3),
),
];
@override
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
final surfaceColor =
context.watch<FrostyThemes>().dark.colorScheme.onSurface;
final surfaceColor = context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface;
final backButton = IconButton(
tooltip: 'Back',
icon: Icon(
Icons.adaptive.arrow_back_rounded,
color: surfaceColor,
shadows: _iconShadow,
),
onPressed: Navigator.of(context).pop,
);
@ -53,18 +74,18 @@ class VideoOverlay extends StatelessWidget {
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay =
!videoStore.settingsStore.fullScreenChatOverlay,
icon: videoStore.settingsStore.fullScreenChatOverlay
? const Icon(Icons.chat_rounded)
: const Icon(Icons.chat_outlined),
? Icon(Icons.chat_rounded, shadows: _iconShadow)
: Icon(Icons.chat_outlined, shadows: _iconShadow),
color: surfaceColor,
),
);
final videoSettingsButton = IconButton(
icon: const Icon(Icons.settings),
icon: Icon(Icons.settings, shadows: _iconShadow),
color: surfaceColor,
onPressed: () {
videoStore.updateStreamQualities();
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
@ -109,35 +130,6 @@ class VideoOverlay extends StatelessWidget {
},
);
final latencyTooltip = Tooltip(
message: 'Latency to broadcaster',
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Observer(
builder: (context) => Text(
videoStore.latency ?? 'N/A',
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
const SizedBox(
width: 8,
),
Icon(
Icons.speed_rounded,
color: surfaceColor,
),
],
),
),
);
final refreshButton = Tooltip(
message: 'Refresh',
preferBelow: false,
@ -145,6 +137,7 @@ class VideoOverlay extends StatelessWidget {
icon: Icon(
Icons.refresh_rounded,
color: surfaceColor,
shadows: _iconShadow,
),
onPressed: videoStore.handleRefresh,
),
@ -161,6 +154,7 @@ class VideoOverlay extends StatelessWidget {
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
color: surfaceColor,
shadows: _iconShadow,
),
onPressed: () => videoStore.settingsStore.fullScreen =
!videoStore.settingsStore.fullScreen,
@ -168,7 +162,7 @@ class VideoOverlay extends StatelessWidget {
);
final rotateButton = Tooltip(
message: orientation == Orientation.portrait
message: context.isPortrait
? 'Enter landscape mode'
: 'Exit landscape mode',
preferBelow: false,
@ -176,17 +170,18 @@ class VideoOverlay extends StatelessWidget {
icon: Icon(
Icons.screen_rotation_rounded,
color: surfaceColor,
shadows: _iconShadow,
),
onPressed: () {
if (orientation == Orientation.portrait) {
if (context.isPortrait) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp],
);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
SystemChrome.setPreferredOrientations([]);
}
},
@ -196,16 +191,127 @@ class VideoOverlay extends StatelessWidget {
return Observer(
builder: (context) {
final streamInfo = videoStore.streamInfo;
final offlineChannelInfo = videoStore.offlineChannelInfo;
// Top gradient - fades from top to bottom, covers top cluster area
final topGradient = BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black, // Solid black for controls
Colors.black.withValues(alpha: 0.95), // Strong coverage
Colors.black.withValues(alpha: 0.88), // Still very strong
Colors.black.withValues(alpha: 0.78), // Strong transition
Colors.black.withValues(alpha: 0.65), // Begin smooth fade
Colors.black.withValues(alpha: 0.48), // Faster fade
Colors.black.withValues(alpha: 0.32), // Quick transition
Colors.black.withValues(alpha: 0.18), // Rapid fade
Colors.black.withValues(alpha: 0.08), // Very light
Colors.black.withValues(alpha: 0.02), // Nearly gone
Colors.transparent, // Transparent end
],
stops: [
0.0, // Top: Full black - solid area for controls
0.1, // Maintain strong coverage for readability
0.2, // Still strong black
0.3, // Begin gradual fade
0.42, // Smooth transition
0.52, // Faster fade point
0.62, // Quick transition
0.7, // Rapid fade
0.8, // Very light
0.9, // Nearly gone
1.0, // Bottom: Fully transparent
],
),
);
// Bottom gradient - fades from bottom to top, covers bottom cluster area
final bottomGradient = BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black, // Solid black for controls
Colors.black.withValues(alpha: 0.95), // Strong coverage
Colors.black.withValues(alpha: 0.88), // Still very strong
Colors.black.withValues(alpha: 0.78), // Strong transition
Colors.black.withValues(alpha: 0.65), // Begin smooth fade
Colors.black.withValues(alpha: 0.48), // Faster fade
Colors.black.withValues(alpha: 0.32), // Quick transition
Colors.black.withValues(alpha: 0.18), // Rapid fade
Colors.black.withValues(alpha: 0.08), // Very light
Colors.black.withValues(alpha: 0.02), // Nearly gone
Colors.transparent, // Transparent end
],
stops: [
0.0, // Bottom: Full black - solid area for controls
0.1, // Maintain strong coverage for readability
0.2, // Still strong black
0.3, // Begin gradual fade
0.42, // Smooth transition
0.52, // Faster fade point
0.62, // Quick transition
0.7, // Rapid fade
0.8, // Very light
0.9, // Nearly gone
1.0, // Top: Fully transparent
],
),
);
if (streamInfo == null) {
return Stack(
children: [
// Top gradient behind top cluster
Positioned(
top: 0,
left: 0,
right: 0,
height: 100, // Covers top area around controls (extended)
child: Container(decoration: topGradient),
),
// Bottom gradient behind bottom cluster
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 80, // Covers bottom area around controls
child: Container(decoration: bottomGradient),
),
// Content
Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
backButton,
const Spacer(),
if (offlineChannelInfo != null)
Flexible(
child: StreamInfoBar(
offlineChannelInfo: offlineChannelInfo,
displayName: chatStore.displayName,
showUptime: false,
showViewerCount: false,
showOfflineIndicator: false,
textColor: surfaceColor,
isOffline: true,
isInSharedChatMode:
chatStore.isInSharedChatMode,
),
),
],
),
),
),
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
context.isLandscape)
chatOverlayButton,
],
),
@ -218,46 +324,67 @@ class VideoOverlay extends StatelessWidget {
// On iPad, hide the rotate button on the overlay
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
if (!isIPad()) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
if (context.isLandscape) fullScreenButton,
],
),
),
],
),
],
);
}
return Stack(
children: [
// Top gradient behind top cluster
Positioned(
top: 0,
left: 0,
right: 0,
height: 100, // Covers top area around controls (extended)
child: Container(decoration: topGradient),
),
// Bottom gradient behind bottom cluster
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 80, // Covers bottom area around controls
child: Container(decoration: bottomGradient),
),
// Content
Stack(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
backButton,
if (orientation == Orientation.landscape)
Expanded(
child: VideoBar(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
backButton,
Flexible(
child: Observer(
builder: (context) => StreamInfoBar(
streamInfo: streamInfo,
titleTextColor: surfaceColor,
subtitleTextColor: surfaceColor,
subtitleTextWeight: FontWeight.w500,
showUptime: false,
showViewerCount: false,
textColor: surfaceColor,
isInSharedChatMode:
chatStore.isInSharedChatMode,
),
),
),
],
),
),
),
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
context.isLandscape)
chatOverlayButton,
if (!Platform.isIOS || isIPad())
Row(
children: [
Text(
videoStore.streamQuality,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
),
),
videoSettingsButton,
],
),
if (!Platform.isIOS || isIPad()) videoSettingsButton,
],
),
Center(
@ -271,6 +398,13 @@ class VideoOverlay extends StatelessWidget {
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: surfaceColor,
shadows: [
Shadow(
offset: const Offset(0, 3),
blurRadius: 8,
color: Colors.black.withValues(alpha: 0.6),
),
],
),
onPressed: videoStore.handlePausePlay,
),
@ -279,39 +413,42 @@ class VideoOverlay extends StatelessWidget {
Align(
alignment: Alignment.bottomLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
spacing: 8,
children: [
Tooltip(
message: 'Stream uptime',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Row(
spacing: 6,
children: [
const Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
const SizedBox(width: 4),
const LiveIndicator(),
Uptime(
startTime: streamInfo.startedAt,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
shadows: _textShadow,
),
),
],
),
),
const SizedBox(width: 12),
Tooltip(
message: 'Viewer count',
preferBelow: false,
child: GestureDetector(
onTap: () => showModalBottomSheet(
onTap: () =>
showModalBottomSheetWithProperFocus(
isScrollControlled: true,
context: context,
builder: (context) => GestureDetector(
@ -325,13 +462,14 @@ class VideoOverlay extends StatelessWidget {
),
),
child: Row(
spacing: 4,
children: [
Icon(
Icons.visibility,
size: 14,
shadows: _iconShadow,
color: surfaceColor,
),
const SizedBox(width: 4),
Text(
NumberFormat().format(
videoStore.streamInfo?.viewerCount,
@ -342,39 +480,81 @@ class VideoOverlay extends StatelessWidget {
fontFeatures: const [
FontFeature.tabularFigures(),
],
shadows: _textShadow,
),
),
],
),
),
),
],
),
),
),
if (orientation == Orientation.landscape &&
settingsStore.showLatency)
latencyTooltip,
Tooltip(
message: 'Enter picture-in-picture',
message: 'Latency to broadcaster',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Row(
spacing: 4,
children: [
Icon(
Icons.speed_rounded,
size: 14,
color: surfaceColor,
shadows: _iconShadow,
),
Observer(
builder: (context) => Text(
videoStore.latency ?? '—',
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
fontFeatures: const [
FontFeature.tabularFigures(),
],
shadows: _textShadow,
),
),
),
],
),
),
],
),
),
),
Builder(
builder: (_) {
// On iOS, show toggle behavior. On Android, always show enter PiP.
final isIOS = Platform.isIOS;
final showExitState = isIOS && videoStore.isInPipMode;
return Tooltip(
message: showExitState
? 'Exit picture-in-picture'
: 'Enter picture-in-picture',
preferBelow: false,
child: IconButton(
icon: Icon(
Icons.picture_in_picture_alt_rounded,
showExitState
? Icons.picture_in_picture_alt_outlined
: Icons.picture_in_picture_alt_rounded,
color: surfaceColor,
shadows: _iconShadow,
),
onPressed: videoStore.requestPictureInPicture,
onPressed: videoStore.togglePictureInPicture,
),
);
},
),
refreshButton,
// On iPad, hide the rotate button on the overlay
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
if (!isIPad()) rotateButton,
if (orientation == Orientation.landscape) fullScreenButton,
if (context.isLandscape) fullScreenButton,
],
),
),
],
),
],
);
},
);

View file

@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -25,6 +26,9 @@ abstract class VideoStoreBase with Store {
/// The userlogin of the current channel.
final String userLogin;
/// The user ID of the current channel.
final String userId;
final AuthStore authStore;
final SettingsStore settingsStore;
@ -50,8 +54,12 @@ abstract class VideoStoreBase with Store {
if (!settingsStore.autoSyncChatDelay) return;
final trimmedLatency = receivedLatency.split(' ')[0];
final latencyAsDouble = double.tryParse(trimmedLatency);
// Parse latency from abbreviated format: "5s" -> 5.0
final numericPart = receivedLatency.replaceAll(
RegExp(r'[^0-9.]'),
'',
);
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.chatDelay = latencyAsDouble;
@ -62,8 +70,9 @@ abstract class VideoStoreBase with Store {
'StreamQualities',
onMessageReceived: (message) async {
final data = jsonDecode(message.message) as List;
_availableStreamQualities =
data.map((item) => item as String).toList();
_availableStreamQualities = data
.map((item) => item as String)
.toList();
if (_firstTimeSettingQuality) {
_firstTimeSettingQuality = false;
if (settingsStore.defaultToHighestQuality) {
@ -89,25 +98,44 @@ abstract class VideoStoreBase with Store {
onMessageReceived: (message) {
_paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].muted = false;',
);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].volume = 1.0;',
);
},
)
..addJavaScriptChannel(
'PipEntered',
onMessageReceived: (message) {
_overlayWasVisibleBeforePip = _overlayVisible;
_isInPipMode = true;
_overlayTimer?.cancel();
_overlayVisible = true;
},
)
..addJavaScriptChannel(
'PipExited',
onMessageReceived: (message) {
_isInPipMode = false;
if (_overlayWasVisibleBeforePip) {
_scheduleOverlayHide();
} else {
_overlayVisible = false;
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) async {
if (url != videoUrl) return;
final injected =
(await videoWebViewController.runJavaScriptReturningResult(
// Safe evaluation of JavaScript boolean result
final result = await videoWebViewController
.runJavaScriptReturningResult(
'window._injected ? true : false',
)) as bool;
);
final injected = result is bool
? result
: (result.toString().toLowerCase() == 'true');
if (injected) return;
await videoWebViewController
.runJavaScript('window._injected = true;');
await videoWebViewController.runJavaScript(
'window._injected = true;',
);
await initVideo();
_acceptContentWarning();
},
@ -115,11 +143,23 @@ abstract class VideoStoreBase with Store {
);
/// The timer that handles hiding the overlay automatically
late Timer _overlayTimer;
Timer? _overlayTimer;
/// Tracks the pre-PiP overlay visibility so we can restore it on exit.
bool _overlayWasVisibleBeforePip = true;
/// The timer that handles periodic stream info updates
Timer? _streamInfoTimer;
/// Tracks the last time stream info was updated to prevent double refresh
DateTime? _lastStreamInfoUpdate;
/// Disposes the overlay reactions.
late final ReactionDisposer _disposeOverlayReaction;
/// Disposes the video mode reaction for timer management.
late final ReactionDisposer _disposeVideoModeReaction;
ReactionDisposer? _disposeAndroidAutoPipReaction;
/// If the video is currently paused.
@ -136,6 +176,10 @@ abstract class VideoStoreBase with Store {
@readonly
StreamTwitch? _streamInfo;
/// The offline channel info, used for displaying channel details when offline.
@readonly
Channel? _offlineChannelInfo;
@readonly
List<String> _availableStreamQualities = [];
@ -150,16 +194,26 @@ abstract class VideoStoreBase with Store {
@readonly
String? _latency;
/// Whether the app is currently in picture-in-picture mode (iOS only).
/// On Android, this state is not tracked since there's no programmatic exit.
@readonly
var _isInPipMode = false;
/// The video URL to use for the webview.
String get videoUrl =>
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
VideoStoreBase({
required this.userLogin,
required this.userId,
required this.twitchApi,
required this.authStore,
required this.settingsStore,
}) {
// Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values
if (settingsStore.autoSyncChatDelay) {
settingsStore.chatDelay = 0.0;
}
// Initialize the video webview params for iOS to enable video autoplay.
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
_videoWebViewParams = WebKitWebViewControllerCreationParams(
@ -177,8 +231,7 @@ abstract class VideoStoreBase with Store {
}
// Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds.
_overlayTimer =
Timer(const Duration(seconds: 5), () => _overlayVisible = false);
_scheduleOverlayHide();
// Initialize a reaction that will reload the webview whenever the overlay is toggled.
_disposeOverlayReaction = reaction(
@ -186,20 +239,41 @@ abstract class VideoStoreBase with Store {
(_) => videoWebViewController.loadRequest(Uri.parse(videoUrl)),
);
// Initialize a reaction to manage stream info timer based on video mode
_disposeVideoModeReaction = reaction((_) => settingsStore.showVideo, (
showVideo,
) {
if (showVideo) {
// In video mode, stop the timer since overlay taps handle refreshing
_stopStreamInfoTimer();
} else {
// In chat-only mode, start the timer for automatic updates
_startStreamInfoTimer();
// Ensure overlay timer is active for clean UI
_scheduleOverlayHide();
}
});
// Check initial state and start timer if already in chat-only mode
if (!settingsStore.showVideo) {
_startStreamInfoTimer();
_scheduleOverlayHide();
}
// On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it.
if (Platform.isAndroid) {
_disposeAndroidAutoPipReaction = autorun(
(_) async {
_disposeAndroidAutoPipReaction = autorun((_) async {
if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) {
pip.setAutoPipMode();
} else {
pip.setAutoPipMode(autoEnter: false);
}
},
);
});
}
updateStreamInfo();
// Stream info timer will be started when entering chat-only mode
}
@action
@ -236,8 +310,9 @@ abstract class VideoStoreBase with Store {
@action
Future<void> setStreamQuality(String newStreamQuality) async {
final indexOfStreamQuality =
_availableStreamQualities.indexOf(newStreamQuality);
final indexOfStreamQuality = _availableStreamQualities.indexOf(
newStreamQuality,
);
if (indexOfStreamQuality == -1) return;
await _setStreamQualityIndex(indexOfStreamQuality);
}
@ -273,14 +348,41 @@ abstract class VideoStoreBase with Store {
const topBar = document.querySelector(".top-bar");
const playerControls = document.querySelector(".player-controls");
const channelDisclosures = document.querySelector("#channel-player-disclosures");
hideElements(topBar, playerControls, channelDisclosures);
const videoPreviewOverlay = document.querySelector('[data-a-target="player-overlay-preview-background"]');
hideElements(topBar, playerControls, channelDisclosures, videoPreviewOverlay);
}
const observer = new MutationObserver(() => {
const videoOverlay = document.querySelector('.video-player__overlay');
if(!videoOverlay) return;
hide();
const videoOverlayObserver = new MutationObserver(hide);
videoOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
// Retry hide() a few times to catch elements that appear with timing variations
setTimeout(hide, 300);
setTimeout(hide, 800);
setTimeout(hide, 1500);
// Disconnect previous observer if exists to prevent memory leaks
if (window._hideOverlayObserver) {
window._hideOverlayObserver.disconnect();
}
// Smart observer: only hide when target elements actually appear in mutations
window._hideOverlayObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
// Only run hide() if a target element or its container was added
if (node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('top-bar') ||
node.classList?.contains('player-controls') ||
node.matches?.('[data-a-target="player-overlay-preview-background"]') ||
node.querySelector?.('.top-bar, .player-controls, #channel-player-disclosures, [data-a-target="player-overlay-preview-background"]'))) {
hide();
return;
}
}
}
});
window._hideOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
@ -309,20 +411,133 @@ abstract class VideoStoreBase with Store {
}
}
/// Sets up intermittent stats panel latency tracking.
///
/// Toggles the stats panel on/off cyclically to minimize CPU overhead.
/// The panel is only active ~10% of the time (2s on, 18s off, every 20s).
Future<void> _listenOnLatencyChanges() async {
try {
await videoWebViewController.runJavaScript('''
_queuePromise(async () => {
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
(await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]')).click();
(await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input')).click();
(await _asyncQuerySelector('[data-a-target="player-overlay-video-stats"]')).style.display = "none";
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
const observer = new MutationObserver((changes) => {
Latency.postMessage(changes[0].target.textContent);
})
observer.observe(document.querySelector('[aria-label="Latency To Broadcaster"]'), { characterData: true, attributes: false, childList: false, subtree: true });
await videoWebViewController.runJavaScript(r'''
window._latencyTracker = {
CYCLE_INTERVAL: 20000,
STATS_ACTIVE_TIME: 2000,
INITIAL_RETRY_INTERVAL: 3000,
MAX_INITIAL_RETRIES: 4,
cycleCount: 0,
hasInitialLatency: false,
async init() {
await this._cycleStatsPanel();
},
async _cycleStatsPanel() {
this.cycleCount++;
const cycleStart = Date.now();
await this._enableStats();
// Wait longer on first cycle for stats to populate
const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME;
await new Promise(resolve => setTimeout(resolve, waitTime));
this._readLatency();
await this._disableStats();
const totalActiveTime = Date.now() - cycleStart;
// Use quick retries until we get initial latency or exceed max retries
let nextInterval;
if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) {
nextInterval = this.INITIAL_RETRY_INTERVAL;
} else {
nextInterval = this.CYCLE_INTERVAL - totalActiveTime;
}
setTimeout(() => this._cycleStatsPanel(), nextInterval);
},
async _enableStats() {
try {
await _queuePromise(async () => {
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
if (!settingsBtn) return;
settingsBtn.click();
const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]');
if (!advancedItem) {
settingsBtn.click();
return;
}
advancedItem.click();
const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input');
if (statsCheckbox && !statsCheckbox.checked) {
statsCheckbox.click();
}
const statsOverlay = await _asyncQuerySelector('[data-a-target="player-overlay-video-stats"]');
if (statsOverlay) {
statsOverlay.style.display = 'none';
}
settingsBtn.click();
});
} catch (error) {
// Silently fail - stats panel may not be available
}
},
async _disableStats() {
try {
await _queuePromise(async () => {
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
if (!settingsBtn) return;
settingsBtn.click();
const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]');
if (!advancedItem) {
settingsBtn.click();
return;
}
advancedItem.click();
const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input');
if (statsCheckbox && statsCheckbox.checked) {
statsCheckbox.click();
}
settingsBtn.click();
});
} catch (error) {
// Silently fail
}
},
_readLatency() {
try {
const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]');
if (latencyElement && latencyElement.textContent) {
let latencyText = latencyElement.textContent.trim();
// Convert to whole number with abbreviated unit: "4.69 sec." -> "5s"
const match = latencyText.match(/([0-9.]+)\s*sec/i);
if (match) {
const rounded = Math.round(parseFloat(match[1]));
latencyText = rounded + 's';
this.hasInitialLatency = true;
}
if (window.Latency) {
Latency.postMessage(latencyText);
}
}
} catch (error) {
// Silently fail
}
}
};
window._latencyTracker.init();
''');
} catch (e) {
debugPrint(e.toString());
@ -342,41 +557,78 @@ abstract class VideoStoreBase with Store {
window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(method, method);
return window._PROMISE_QUEUE;
};
window._asyncQuerySelector = (selector, timeout = undefined) => new Promise((resolve) => {
window._asyncQuerySelector = (selector, timeout = 30000) => new Promise((resolve) => {
let element = document.querySelector(selector);
if (element) {
return resolve(element);
}
let timeoutId;
const observer = new MutationObserver(() => {
element = document.querySelector(selector);
if (element) {
observer.disconnect();
clearTimeout(timeoutId);
resolve(element);
}
});
// Must use subtree to detect nested elements in the player
observer.observe(document.body, { childList: true, subtree: true });
if (timeout) {
setTimeout(() => {
// Always set timeout with default of 30 seconds
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(undefined);
}, timeout);
}
});
_queuePromise(async () => {
const videoElement = await _asyncQuerySelector("video");
// Null check in case element was not found within timeout
if (!videoElement) {
console.warn("Video element not found within timeout");
return;
}
// Prevent duplicate event listener registration
if (!videoElement._listenersAdded) {
videoElement._listenersAdded = true;
videoElement.addEventListener("pause", () => {
VideoPause.postMessage("video paused");
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
videoElement.textTracks[0].mode = "hidden";
}
});
videoElement.addEventListener("playing", () => {
VideoPlaying.postMessage("video playing");
// Ensure video is unmuted and captions are hidden
videoElement.muted = false;
videoElement.volume = 1.0;
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
videoElement.textTracks[0].mode = "hidden";
}
});
// Add PiP event listeners for iOS
videoElement.addEventListener("enterpictureinpicture", () => {
PipEntered.postMessage("pip entered");
});
videoElement.addEventListener("leavepictureinpicture", () => {
PipExited.postMessage("pip exited");
});
}
if (!videoElement.paused) {
VideoPlaying.postMessage("video playing");
// Ensure video is unmuted and captions are hidden
videoElement.muted = false;
videoElement.volume = 1.0;
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
videoElement.textTracks[0].mode = "hidden";
}
}
});
''');
if (settingsStore.showOverlay) {
@ -393,35 +645,104 @@ abstract class VideoStoreBase with Store {
/// Called whenever the video/overlay is tapped.
@action
void handleVideoTap() {
_overlayTimer.cancel();
if (_isInPipMode) {
_overlayVisible = true;
return;
}
_overlayTimer?.cancel();
if (_overlayVisible) {
_overlayVisible = false;
} else {
updateStreamInfo();
updateStreamInfo(forceUpdate: true);
_overlayVisible = true;
_overlayTimer =
Timer(const Duration(seconds: 5), () => _overlayVisible = false);
_scheduleOverlayHide();
}
}
/// Starts the periodic stream info timer for chat-only mode.
void _startStreamInfoTimer() {
// Only start if not already active
if (_streamInfoTimer?.isActive != true) {
_streamInfoTimer = Timer.periodic(
const Duration(seconds: 60),
(_) => updateStreamInfo(),
);
}
}
/// Stops the periodic stream info timer.
void _stopStreamInfoTimer() {
if (_streamInfoTimer?.isActive == true) {
_streamInfoTimer?.cancel();
}
}
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
_overlayTimer?.cancel();
if (_isInPipMode) {
_overlayVisible = true;
return;
}
_overlayTimer = Timer(delay, () {
if (_isInPipMode) return;
runInAction(() {
_overlayVisible = false;
});
});
}
/// Handles app resume event for immediate stream info refresh in chat-only mode.
@action
void handleAppResume() {
// Only refresh immediately in chat-only mode
if (!settingsStore.showVideo) {
updateStreamInfo(forceUpdate: true);
}
}
/// Updates the stream info from the Twitch API.
///
/// If the stream is offline, disables the overlay.
/// If the stream is offline, fetches channel information to show offline details.
/// Set [forceUpdate] to true to bypass the rate limiting check.
@action
Future<void> updateStreamInfo() async {
try {
_streamInfo = await twitchApi.getStream(
userLogin: userLogin,
headers: authStore.headersTwitch,
);
} catch (e) {
debugPrint(e.toString());
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
// Rate limiting: prevent too frequent updates unless forced
final now = DateTime.now();
if (!forceUpdate && _lastStreamInfoUpdate != null) {
final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!);
if (timeSinceLastUpdate.inSeconds < 5) {
return; // Skip update if less than 5 seconds since last update
}
}
_overlayTimer.cancel();
_lastStreamInfoUpdate = now;
try {
_streamInfo = await twitchApi.getStream(userLogin: userLogin);
// Clear offline info when stream is live
_offlineChannelInfo = null;
} catch (e) {
_overlayTimer?.cancel();
_streamInfo = null;
_paused = true;
// Try to fetch offline channel information
try {
_offlineChannelInfo = await twitchApi.getChannel(userId: userId);
} catch (channelError) {
_offlineChannelInfo = null;
}
// Restart overlay timer in chat-only mode even on error
if (!settingsStore.showVideo) {
_scheduleOverlayHide();
}
}
}
@ -437,21 +758,30 @@ abstract class VideoStoreBase with Store {
if (settingsStore.showOverlay) {
_overlayVisible = true;
_scheduleOverlayHide(const Duration(seconds: 3));
}
}
_overlayTimer.cancel();
_overlayTimer =
Timer(const Duration(seconds: 3), () => _overlayVisible = false);
}
}
// Stream info timer is managed automatically by the video mode reaction
}
/// Refreshes the stream webview and updates the stream info.
///
/// Performs a hard refresh by loading about:blank first to completely clear
/// the webview state, then immediately reloads the video URL. This is more
/// reliable than a soft reload when the webview is sluggish.
@action
void handleRefresh() {
Future<void> handleRefresh() async {
HapticFeedback.lightImpact();
_paused = true;
_firstTimeSettingQuality = true;
videoWebViewController.reload();
_isInPipMode = false;
// Hard refresh: clear everything by loading blank page first
await videoWebViewController.loadRequest(Uri.parse('about:blank'));
// Then immediately load the video URL for a fresh start
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
updateStreamInfo();
}
@ -459,8 +789,9 @@ abstract class VideoStoreBase with Store {
void handlePausePlay() {
try {
if (_paused) {
videoWebViewController
.runJavaScript('document.getElementsByTagName("video")[0].play();');
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].play();',
);
} else {
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].pause();',
@ -489,6 +820,32 @@ abstract class VideoStoreBase with Store {
}
}
/// Toggle picture-in-picture mode.
///
/// If not in PiP mode, enters PiP mode.
/// If already in PiP mode on iOS, exits PiP mode.
/// On Android, always enters PiP mode (no programmatic exit or state tracking).
@action
void togglePictureInPicture() {
try {
if (Platform.isIOS && _isInPipMode) {
// Exit PiP mode on iOS
videoWebViewController.runJavaScript('''
(function() {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
}
})();
''');
} else {
// Enter PiP mode (both iOS and Android)
requestPictureInPicture();
}
} catch (e) {
debugPrint(e.toString());
}
}
@action
void dispose() {
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
@ -498,9 +855,20 @@ abstract class VideoStoreBase with Store {
});
}
_overlayTimer.cancel();
_overlayTimer?.cancel();
_streamInfoTimer?.cancel();
_disposeOverlayReaction();
_disposeVideoModeReaction();
_disposeAndroidAutoPipReaction?.call();
// Note: JavaScript cleanup and channel removal are unnecessary here.
// The Video widget loads about:blank during disposal (video.dart:63-65),
// which automatically clears all JavaScript state, including:
// - Event listeners (play/pause/pip)
// - Latency tracker intermittent cycling
// - MutationObservers (_hideOverlayObserver)
// - JavaScript channels
// Attempting cleanup here causes a race condition crash.
}
}

View file

@ -9,8 +9,10 @@ part of 'video_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$VideoStore on VideoStoreBase, Store {
late final _$_pausedAtom =
Atom(name: 'VideoStoreBase._paused', context: context);
late final _$_pausedAtom = Atom(
name: 'VideoStoreBase._paused',
context: context,
);
bool get paused {
_$_pausedAtom.reportRead();
@ -27,8 +29,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_overlayVisibleAtom =
Atom(name: 'VideoStoreBase._overlayVisible', context: context);
late final _$_overlayVisibleAtom = Atom(
name: 'VideoStoreBase._overlayVisible',
context: context,
);
bool get overlayVisible {
_$_overlayVisibleAtom.reportRead();
@ -45,8 +49,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_streamInfoAtom =
Atom(name: 'VideoStoreBase._streamInfo', context: context);
late final _$_streamInfoAtom = Atom(
name: 'VideoStoreBase._streamInfo',
context: context,
);
StreamTwitch? get streamInfo {
_$_streamInfoAtom.reportRead();
@ -63,8 +69,30 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_availableStreamQualitiesAtom =
Atom(name: 'VideoStoreBase._availableStreamQualities', context: context);
late final _$_offlineChannelInfoAtom = Atom(
name: 'VideoStoreBase._offlineChannelInfo',
context: context,
);
Channel? get offlineChannelInfo {
_$_offlineChannelInfoAtom.reportRead();
return super._offlineChannelInfo;
}
@override
Channel? get _offlineChannelInfo => offlineChannelInfo;
@override
set _offlineChannelInfo(Channel? value) {
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
super._offlineChannelInfo = value;
});
}
late final _$_availableStreamQualitiesAtom = Atom(
name: 'VideoStoreBase._availableStreamQualities',
context: context,
);
List<String> get availableStreamQualities {
_$_availableStreamQualitiesAtom.reportRead();
@ -76,14 +104,19 @@ mixin _$VideoStore on VideoStoreBase, Store {
@override
set _availableStreamQualities(List<String> value) {
_$_availableStreamQualitiesAtom
.reportWrite(value, super._availableStreamQualities, () {
_$_availableStreamQualitiesAtom.reportWrite(
value,
super._availableStreamQualities,
() {
super._availableStreamQualities = value;
});
},
);
}
late final _$_streamQualityIndexAtom =
Atom(name: 'VideoStoreBase._streamQualityIndex', context: context);
late final _$_streamQualityIndexAtom = Atom(
name: 'VideoStoreBase._streamQualityIndex',
context: context,
);
int get streamQualityIndex {
_$_streamQualityIndexAtom.reportRead();
@ -100,8 +133,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$_latencyAtom =
Atom(name: 'VideoStoreBase._latency', context: context);
late final _$_latencyAtom = Atom(
name: 'VideoStoreBase._latency',
context: context,
);
String? get latency {
_$_latencyAtom.reportRead();
@ -118,56 +153,104 @@ mixin _$VideoStore on VideoStoreBase, Store {
});
}
late final _$updateStreamQualitiesAsyncAction =
AsyncAction('VideoStoreBase.updateStreamQualities', context: context);
late final _$_isInPipModeAtom = Atom(
name: 'VideoStoreBase._isInPipMode',
context: context,
);
bool get isInPipMode {
_$_isInPipModeAtom.reportRead();
return super._isInPipMode;
}
@override
bool get _isInPipMode => isInPipMode;
@override
set _isInPipMode(bool value) {
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
super._isInPipMode = value;
});
}
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
'VideoStoreBase.updateStreamQualities',
context: context,
);
@override
Future<void> updateStreamQualities() {
return _$updateStreamQualitiesAsyncAction
.run(() => super.updateStreamQualities());
return _$updateStreamQualitiesAsyncAction.run(
() => super.updateStreamQualities(),
);
}
late final _$setStreamQualityAsyncAction =
AsyncAction('VideoStoreBase.setStreamQuality', context: context);
late final _$setStreamQualityAsyncAction = AsyncAction(
'VideoStoreBase.setStreamQuality',
context: context,
);
@override
Future<void> setStreamQuality(String newStreamQuality) {
return _$setStreamQualityAsyncAction
.run(() => super.setStreamQuality(newStreamQuality));
return _$setStreamQualityAsyncAction.run(
() => super.setStreamQuality(newStreamQuality),
);
}
late final _$_setStreamQualityIndexAsyncAction =
AsyncAction('VideoStoreBase._setStreamQualityIndex', context: context);
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
'VideoStoreBase._setStreamQualityIndex',
context: context,
);
@override
Future<void> _setStreamQualityIndex(int newStreamQualityIndex) {
return _$_setStreamQualityIndexAsyncAction
.run(() => super._setStreamQualityIndex(newStreamQualityIndex));
return _$_setStreamQualityIndexAsyncAction.run(
() => super._setStreamQualityIndex(newStreamQualityIndex),
);
}
late final _$initVideoAsyncAction =
AsyncAction('VideoStoreBase.initVideo', context: context);
late final _$initVideoAsyncAction = AsyncAction(
'VideoStoreBase.initVideo',
context: context,
);
@override
Future<void> initVideo() {
return _$initVideoAsyncAction.run(() => super.initVideo());
}
late final _$updateStreamInfoAsyncAction =
AsyncAction('VideoStoreBase.updateStreamInfo', context: context);
late final _$updateStreamInfoAsyncAction = AsyncAction(
'VideoStoreBase.updateStreamInfo',
context: context,
);
@override
Future<void> updateStreamInfo() {
return _$updateStreamInfoAsyncAction.run(() => super.updateStreamInfo());
Future<void> updateStreamInfo({bool forceUpdate = false}) {
return _$updateStreamInfoAsyncAction.run(
() => super.updateStreamInfo(forceUpdate: forceUpdate),
);
}
late final _$VideoStoreBaseActionController =
ActionController(name: 'VideoStoreBase', context: context);
late final _$handleRefreshAsyncAction = AsyncAction(
'VideoStoreBase.handleRefresh',
context: context,
);
@override
Future<void> handleRefresh() {
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
}
late final _$VideoStoreBaseActionController = ActionController(
name: 'VideoStoreBase',
context: context,
);
@override
void handleVideoTap() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleVideoTap');
name: 'VideoStoreBase.handleVideoTap',
);
try {
return super.handleVideoTap();
} finally {
@ -175,10 +258,23 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
}
@override
void handleAppResume() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleAppResume',
);
try {
return super.handleAppResume();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
void handleToggleOverlay() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleToggleOverlay');
name: 'VideoStoreBase.handleToggleOverlay',
);
try {
return super.handleToggleOverlay();
} finally {
@ -187,11 +283,12 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
@override
void handleRefresh() {
void togglePictureInPicture() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.handleRefresh');
name: 'VideoStoreBase.togglePictureInPicture',
);
try {
return super.handleRefresh();
return super.togglePictureInPicture();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
@ -200,7 +297,8 @@ mixin _$VideoStore on VideoStoreBase, Store {
@override
void dispose() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.dispose');
name: 'VideoStoreBase.dispose',
);
try {
return super.dispose();
} finally {

View file

@ -10,6 +10,7 @@ import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/release_notes.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
@ -59,13 +60,41 @@ class _HomeState extends State<Home> {
SystemChrome.setPreferredOrientations([]);
final isLoggedIn = _authStore.isLoggedIn && _authStore.user.details != null;
final theme = Theme.of(context);
return GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
extendBody: true,
extendBodyBehindAppBar: true,
appBar: AppBar(
centerTitle: false,
shape: const Border(),
elevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
titleSpacing: 16,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: theme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
),
flexibleSpace: Observer(
builder: (_) {
// Only show flexible space on Following tab
final isOnFollowingTab =
isLoggedIn && _homeStore.selectedIndex == 0;
// Only show flexible space when on Following tab
if (!isOnFollowingTab) return const SizedBox.shrink();
return BlurredContainer(
gradientDirection: GradientDirection.up,
child: const SizedBox.expand(),
);
},
),
title: Observer(
builder: (_) {
final titles = [
@ -99,8 +128,7 @@ class _HomeState extends State<Home> {
),
],
),
body: SafeArea(
child: Observer(
body: Observer(
builder: (_) => IndexedStack(
index: _homeStore.selectedIndex,
children: [
@ -109,45 +137,69 @@ class _HomeState extends State<Home> {
listType: ListType.followed,
scrollController: _homeStore.followedScrollController,
),
TopSection(
homeStore: _homeStore,
),
Search(
scrollController: _homeStore.searchScrollController,
),
TopSection(homeStore: _homeStore),
Search(scrollController: _homeStore.searchScrollController),
],
),
),
),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
Observer(
builder: (_) => NavigationBar(
bottomNavigationBar: BlurredContainer(
gradientDirection: GradientDirection.down,
child: Observer(
builder: (_) => Theme(
data: Theme.of(
context,
).copyWith(splashFactory: NoSplash.splashFactory),
child: NavigationBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
destinations: [
if (_authStore.isLoggedIn)
const NavigationDestination(
NavigationDestination(
icon: Icon(
Icons.favorite_border_rounded,
color: _homeStore.selectedIndex == 0
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
selectedIcon: Icon(
Icons.favorite_rounded,
color: theme.colorScheme.onSurface,
),
selectedIcon: Icon(Icons.favorite_rounded),
label: 'Following',
tooltip: 'Following',
),
const NavigationDestination(
NavigationDestination(
icon: Icon(
Icons.arrow_upward_rounded,
color: _homeStore.selectedIndex == (isLoggedIn ? 1 : 0)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
selectedIcon: Icon(
Icons.arrow_upward_rounded,
color: theme.colorScheme.onSurface,
),
selectedIcon: Icon(Icons.arrow_upward_rounded),
label: 'Top',
tooltip: 'Top',
),
const NavigationDestination(
NavigationDestination(
icon: Icon(
Icons.search_rounded,
color: _homeStore.selectedIndex == (isLoggedIn ? 2 : 1)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
selectedIcon: Icon(
Icons.search_rounded,
color: theme.colorScheme.onSurface,
),
selectedIcon: Icon(Icons.search_rounded),
label: 'Search',
tooltip: 'Search',
),
@ -156,7 +208,7 @@ class _HomeState extends State<Home> {
onDestinationSelected: _homeStore.handleTap,
),
),
],
),
),
),
);

View file

@ -16,15 +16,13 @@ abstract class HomeStoreBase with Store {
/// The scroll controllers for controlling the scroll to top on the top section.
/// One controller is for the top streams tab and the other is for the top categories tab.
final topSectionScrollControllers = [
ScrollController(),
ScrollController(),
];
final topSectionScrollControllers = [ScrollController(), ScrollController()];
/// The scroll controller for controlling the scroll to top on the search section.
final searchScrollController = ScrollController();
/// The current index of the top section tab. Changes when switching between the streams and categories tabs.
@observable
var topSectionCurrentIndex = 0;
/// The current selected index/tab of the bottom navigation bar.
@ -99,6 +97,7 @@ abstract class HomeStoreBase with Store {
_disposeReaction();
followedScrollController.dispose();
searchScrollController.dispose();
for (final controller in topSectionScrollControllers) {
controller.dispose();

View file

@ -9,8 +9,32 @@ part of 'home_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$HomeStore on HomeStoreBase, Store {
late final _$_selectedIndexAtom =
Atom(name: 'HomeStoreBase._selectedIndex', context: context);
late final _$topSectionCurrentIndexAtom = Atom(
name: 'HomeStoreBase.topSectionCurrentIndex',
context: context,
);
@override
int get topSectionCurrentIndex {
_$topSectionCurrentIndexAtom.reportRead();
return super.topSectionCurrentIndex;
}
@override
set topSectionCurrentIndex(int value) {
_$topSectionCurrentIndexAtom.reportWrite(
value,
super.topSectionCurrentIndex,
() {
super.topSectionCurrentIndex = value;
},
);
}
late final _$_selectedIndexAtom = Atom(
name: 'HomeStoreBase._selectedIndex',
context: context,
);
int get selectedIndex {
_$_selectedIndexAtom.reportRead();
@ -27,13 +51,16 @@ mixin _$HomeStore on HomeStoreBase, Store {
});
}
late final _$HomeStoreBaseActionController =
ActionController(name: 'HomeStoreBase', context: context);
late final _$HomeStoreBaseActionController = ActionController(
name: 'HomeStoreBase',
context: context,
);
@override
void handleTap(int index) {
final _$actionInfo = _$HomeStoreBaseActionController.startAction(
name: 'HomeStoreBase.handleTap');
name: 'HomeStoreBase.handleTap',
);
try {
return super.handleTap(index);
} finally {
@ -44,7 +71,7 @@ mixin _$HomeStore on HomeStoreBase, Store {
@override
String toString() {
return '''
topSectionCurrentIndex: ${topSectionCurrentIndex}
''';
}
}

View file

@ -5,9 +5,11 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/home/search/search_results_categories.dart';
import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
@ -16,15 +18,15 @@ class Search extends StatefulWidget {
// The scroll controller for handling scroll to top functionality.
final ScrollController scrollController;
const Search({
super.key,
required this.scrollController,
});
const Search({super.key, required this.scrollController});
@override
State<Search> createState() => _SearchState();
}
// Constants for consistent sizing
const double _kSearchBarHeight = 80.0;
class _SearchState extends State<Search> {
late final _searchStore = SearchStore(
authStore: context.read<AuthStore>(),
@ -33,56 +35,61 @@ class _SearchState extends State<Search> {
@override
Widget build(BuildContext context) {
return Column(
return Stack(
children: [
Observer(
builder: (context) {
return Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchStore.textEditingController,
focusNode: _searchStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Find a channel or category',
suffixIcon: _searchStore.textFieldFocusNode.hasFocus ||
_searchStore.searchText.isNotEmpty
? IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: _searchStore.searchText.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (_searchStore.searchText.isEmpty) {
_searchStore.textFieldFocusNode.unfocus();
}
_searchStore.textEditingController.clear();
},
)
: null,
),
onSubmitted: _searchStore.handleQuery,
),
);
},
),
AnimatedScrollBorder(scrollController: widget.scrollController),
Expanded(
child: Scrollbar(
controller: widget.scrollController,
// Content behind the search bar
Positioned.fill(
child: Observer(
builder: (context) {
if (_searchStore.textEditingController.text.isEmpty) {
if (_searchStore.searchHistory.isEmpty) {
return const AlertMessage(message: 'No recent searches');
// Keep controller attached so borders stay in sync
return CustomScrollView(
controller: widget.scrollController,
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height:
MediaQuery.of(context).padding.top +
_kSearchBarHeight,
),
),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: const AlertMessage(
message: 'No recent searches',
vertical: true,
),
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return FrostyScrollbar(
controller: widget.scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + _kSearchBarHeight,
bottom: MediaQuery.of(context).padding.bottom,
),
child: ListView(
controller: widget.scrollController,
padding: EdgeInsets.only(
top:
MediaQuery.of(context).padding.top +
_kSearchBarHeight,
bottom: MediaQuery.of(context).padding.bottom,
),
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 4),
padding: EdgeInsets.only(
left: 16 + MediaQuery.of(context).padding.left,
right: 4 + MediaQuery.of(context).padding.right,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -98,11 +105,7 @@ class _SearchState extends State<Search> {
],
),
),
Expanded(
child: ListView(
controller: widget.scrollController,
children: _searchStore.searchHistory
.mapIndexed(
..._searchStore.searchHistory.mapIndexed(
(index, searchTerm) => ListTile(
leading: const Icon(Icons.history_rounded),
title: Text(searchTerm),
@ -110,42 +113,147 @@ class _SearchState extends State<Search> {
_searchStore.textEditingController.text =
searchTerm;
_searchStore.handleQuery(searchTerm);
_searchStore.textEditingController
.selection = TextSelection.fromPosition(
_searchStore.textEditingController.selection =
TextSelection.fromPosition(
TextPosition(
offset: _searchStore
.textEditingController.text.length,
.textEditingController
.text
.length,
),
);
},
),
)
.toList(),
),
),
],
),
);
}
return CustomScrollView(
return FrostyScrollbar(
controller: widget.scrollController,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + _kSearchBarHeight,
bottom: MediaQuery.of(context).padding.bottom,
),
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
const SliverToBoxAdapter(
child: SectionHeader(
// Add padding for app bar and search bar
_SearchTopPadding(),
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Channels',
isFirst: true,
padding: EdgeInsets.fromLTRB(16, 12, 16, 8),
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
12,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
SearchResultsChannels(
searchStore: _searchStore,
query: _searchStore.searchText,
),
const SliverToBoxAdapter(
child: SectionHeader(
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Categories',
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
SearchResultsCategories(searchStore: _searchStore),
// Add padding for bottom navigation bar
const SliverBottomPadding(),
],
),
);
},
),
),
// Blurred search bar on top
Positioned(
top: 0,
left: 0,
right: 0,
child: BlurredContainer(
gradientDirection: GradientDirection.up,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: Observer(
builder: (context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchStore.textEditingController,
focusNode: _searchStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Search channels or categories',
suffixIcon:
_searchStore.textFieldFocusNode.hasFocus ||
_searchStore.searchText.isNotEmpty
? IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: _searchStore.searchText.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (_searchStore.searchText.isEmpty) {
_searchStore.textFieldFocusNode.unfocus();
}
_searchStore.textEditingController.clear();
// Ensure borders reset: scroll back to top and trigger listeners
if (widget.scrollController.hasClients) {
widget.scrollController.animateTo(
0,
duration: const Duration(
milliseconds: 150,
),
curve: Curves.easeOut,
);
}
// After the frame (when content swaps), re-evaluate border state
WidgetsBinding.instance.addPostFrameCallback((
_,
) {
if (widget.scrollController.hasClients) {
// Nudge listeners even if already at zero
final offset =
widget.scrollController.offset;
final target = (offset == 0)
? 0.01
: 0.0;
widget.scrollController.jumpTo(
(offset == 0) ? target : 0.0,
);
if (target == 0.01) {
widget.scrollController.jumpTo(0.0);
}
}
});
},
)
: null,
),
onSubmitted: _searchStore.handleQuery,
),
),
],
);
},
@ -162,3 +270,17 @@ class _SearchState extends State<Search> {
super.dispose();
}
}
/// Helper widget for consistent top padding in search slivers
class _SearchTopPadding extends StatelessWidget {
const _SearchTopPadding();
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).padding.top + _kSearchBarHeight,
),
);
}
}

View file

@ -4,7 +4,7 @@ import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:mobx/mobx.dart';
class SearchResultsCategories extends StatelessWidget {
@ -18,21 +18,24 @@ class SearchResultsCategories extends StatelessWidget {
builder: (context) {
final future = searchStore.categoryFuture;
switch (future!.status) {
if (future == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
switch (future.status) {
case FutureStatus.pending:
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: LoadingIndicator(
subtitle: 'Loading categories...',
),
),
return SliverList.builder(
itemCount: 8,
itemBuilder: (context, index) => const CategorySkeletonLoader(),
);
case FutureStatus.rejected:
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(message: 'Failed to get categories'),
child: AlertMessage(
message: 'Unable to load categories',
vertical: true,
),
),
);
case FutureStatus.fulfilled:
@ -42,7 +45,10 @@ class SearchResultsCategories extends StatelessWidget {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(message: 'Failed to get categories'),
child: AlertMessage(
message: 'Failed to get categories',
vertical: true,
),
),
);
}
@ -51,16 +57,18 @@ class SearchResultsCategories extends StatelessWidget {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(message: 'No matching categories'),
child: AlertMessage(
message: 'No matching categories',
vertical: true,
),
),
);
}
return SliverList.builder(
itemCount: categories.data.length,
itemBuilder: (context, index) => CategoryCard(
category: categories.data[index],
),
itemBuilder: (context, index) =>
CategoryCard(category: categories.data[index]),
);
}
},

View file

@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/live_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:mobx/mobx.dart';
@ -43,10 +46,18 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
),
);
} on ApiException catch (e) {
debugPrint('Search channels ApiException: $e');
final snackBar = SnackBar(
content: AlertMessage(message: e.message, centered: false),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(snackBar);
} catch (error) {
debugPrint('Search channels error: $error');
final snackBar = SnackBar(
content: AlertMessage(
message: error.toString(),
message: 'Unable to follow channel',
centered: false,
),
);
@ -61,18 +72,25 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
builder: (context) {
final future = widget.searchStore.channelFuture;
switch (future!.status) {
if (future == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
switch (future.status) {
case FutureStatus.pending:
return const SliverToBoxAdapter(
child: LoadingIndicator(
subtitle: 'Loading channels...',
),
return SliverList.builder(
itemCount: 8,
itemBuilder: (context, index) =>
ChannelSkeletonLoader(index: index),
);
case FutureStatus.rejected:
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: AlertMessage(message: 'Failed to get channels'),
child: AlertMessage(
message: 'Unable to load channels',
vertical: true,
),
),
);
case FutureStatus.fulfilled:
@ -84,8 +102,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
return SliverList.list(
children: [
...results.map(
(channel) {
...results.map((channel) {
final displayName = getReadableName(
channel.displayName,
channel.broadcasterLogin,
@ -105,7 +122,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
onLongPress: () {
HapticFeedback.lightImpact();
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => UserActionsModal(
authStore: widget.searchStore.authStore,
@ -123,21 +140,16 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
subtitle: channel.isLive
? Row(
spacing: 6,
children: [
const Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
const SizedBox(width: 5),
const LiveIndicator(),
Uptime(startTime: channel.startedAt),
],
)
: null,
),
);
},
),
}),
ListTile(
title: Text('Go to channel "${widget.query}"'),
onTap: () => _handleSearch(context, widget.query),

View file

@ -41,7 +41,8 @@ abstract class SearchStoreBase with Store {
final prefs = await SharedPreferences.getInstance();
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
_searchHistory = prefs.getStringList('search_history')?.asObservable() ??
_searchHistory =
prefs.getStringList('search_history')?.asObservable() ??
ObservableList<String>();
// Create a reaction that will limit the history to 8 entries and update it to local storage automatically.
@ -51,8 +52,9 @@ abstract class SearchStoreBase with Store {
});
// Add a listener to update the cancel button visibility whenever the text changes.
textEditingController
.addListener(() => _searchText = textEditingController.text);
textEditingController.addListener(
() => _searchText = textEditingController.text,
);
}
/// Obtain the channels and categories that match the provided [query].
@ -65,28 +67,19 @@ abstract class SearchStoreBase with Store {
_searchHistory.insert(0, query);
// Fetch the matching channels, sort it by live status, and then set it.
_channelFuture = twitchApi
.searchChannels(
query: query,
headers: authStore.headersTwitch,
)
.then(
(channels) {
_channelFuture = twitchApi.searchChannels(query: query).then((channels) {
channels.sort((c1, c2) => c2.isLive ? 1 : -1);
return channels;
},
).asObservable();
}).asObservable();
// Fetch and set the categories that match the query.
_categoryFuture = twitchApi
.searchCategories(
query: query,
headers: authStore.headersTwitch,
)
.then((categories) {
_categoryFuture = twitchApi.searchCategories(query: query).then((
categories,
) {
// Move exact matches to the first result
final matchingIndex = categories.data
.indexWhere((c) => c.name.toLowerCase() == query.toLowerCase());
final matchingIndex = categories.data.indexWhere(
(c) => c.name.toLowerCase() == query.toLowerCase(),
);
if (matchingIndex >= 1) {
final matchingCategory = categories.data.removeAt(matchingIndex);
categories.data.insert(0, matchingCategory);
@ -98,14 +91,8 @@ abstract class SearchStoreBase with Store {
/// Find a specific channel provided the [query].
/// This is used for channels that may not show up in the search results.
Future<Channel> searchChannel(String query) async {
final user = await twitchApi.getUser(
userLogin: query,
headers: authStore.headersTwitch,
);
return await twitchApi.getChannel(
userId: user.id,
headers: authStore.headersTwitch,
);
final user = await twitchApi.getUser(userLogin: query);
return await twitchApi.getChannel(userId: user.id);
}
void dispose() {

View file

@ -9,8 +9,10 @@ part of 'search_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$SearchStore on SearchStoreBase, Store {
late final _$_searchTextAtom =
Atom(name: 'SearchStoreBase._searchText', context: context);
late final _$_searchTextAtom = Atom(
name: 'SearchStoreBase._searchText',
context: context,
);
String get searchText {
_$_searchTextAtom.reportRead();
@ -27,8 +29,10 @@ mixin _$SearchStore on SearchStoreBase, Store {
});
}
late final _$_searchHistoryAtom =
Atom(name: 'SearchStoreBase._searchHistory', context: context);
late final _$_searchHistoryAtom = Atom(
name: 'SearchStoreBase._searchHistory',
context: context,
);
ObservableList<String> get searchHistory {
_$_searchHistoryAtom.reportRead();
@ -45,8 +49,10 @@ mixin _$SearchStore on SearchStoreBase, Store {
});
}
late final _$_channelFutureAtom =
Atom(name: 'SearchStoreBase._channelFuture', context: context);
late final _$_channelFutureAtom = Atom(
name: 'SearchStoreBase._channelFuture',
context: context,
);
ObservableFuture<List<ChannelQuery>>? get channelFuture {
_$_channelFutureAtom.reportRead();
@ -63,8 +69,10 @@ mixin _$SearchStore on SearchStoreBase, Store {
});
}
late final _$_categoryFutureAtom =
Atom(name: 'SearchStoreBase._categoryFuture', context: context);
late final _$_categoryFutureAtom = Atom(
name: 'SearchStoreBase._categoryFuture',
context: context,
);
ObservableFuture<CategoriesTwitch?>? get categoryFuture {
_$_categoryFutureAtom.reportRead();
@ -81,21 +89,26 @@ mixin _$SearchStore on SearchStoreBase, Store {
});
}
late final _$initAsyncAction =
AsyncAction('SearchStoreBase.init', context: context);
late final _$initAsyncAction = AsyncAction(
'SearchStoreBase.init',
context: context,
);
@override
Future<void> init() {
return _$initAsyncAction.run(() => super.init());
}
late final _$SearchStoreBaseActionController =
ActionController(name: 'SearchStoreBase', context: context);
late final _$SearchStoreBaseActionController = ActionController(
name: 'SearchStoreBase',
context: context,
);
@override
void handleQuery(String query) {
final _$actionInfo = _$SearchStoreBaseActionController.startAction(
name: 'SearchStoreBase.handleQuery');
name: 'SearchStoreBase.handleQuery',
);
try {
return super.handleQuery(query);
} finally {

View file

@ -4,15 +4,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class LargeStreamCard extends StatelessWidget {
@ -45,26 +43,11 @@ class LargeStreamCard extends StatelessWidget {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ 1, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final surfaceColor =
context.watch<FrostyThemes>().dark.colorScheme.onSurface;
final thumbnail = ClipRRect(
final thumbnail = SizedBox(
width: double.infinity,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Container(
foregroundDecoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.8, 1.0],
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: FrostyCachedNetworkImage(
@ -73,70 +56,19 @@ class LargeStreamCard extends StatelessWidget {
'-${thumbnailWidth}x$thumbnailHeight',
),
cacheKey: cacheKey,
placeholder: (context, url) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: const LoadingIndicator(),
placeholder: (context, url) => const SkeletonLoader(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
useOldImageOnUrlChange: true,
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Tooltip(
message: 'Stream uptime',
preferBelow: false,
child: Row(
children: [
const Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
const SizedBox(width: 4),
Uptime(
startTime: streamInfo.startedAt,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 12),
Tooltip(
message: 'Viewer count',
preferBelow: false,
child: Row(
children: [
Icon(
Icons.visibility,
size: 14,
color: surfaceColor,
),
const SizedBox(width: 4),
Text(
NumberFormat().format(streamInfo.viewerCount),
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
],
),
);
final streamerName =
getReadableName(streamInfo.userName, streamInfo.userLogin);
final streamerName = getReadableName(
streamInfo.userName,
streamInfo.userLogin,
);
return InkWell(
onTap: () => Navigator.push(
@ -152,7 +84,7 @@ class LargeStreamCard extends StatelessWidget {
onLongPress: () {
HapticFeedback.mediumImpact();
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => UserActionsModal(
authStore: context.read<AuthStore>(),
@ -165,17 +97,20 @@ class LargeStreamCard extends StatelessWidget {
);
},
child: Padding(
padding: EdgeInsets.symmetric(
vertical: showThumbnail ? 12 : 4,
horizontal: 16,
padding: EdgeInsets.only(
top: showThumbnail ? 12 : 4,
bottom: showThumbnail ? 12 : 4,
left: 16 + MediaQuery.of(context).padding.left,
right: 16 + MediaQuery.of(context).padding.right,
),
child: Column(
children: [
if (showThumbnail) thumbnail,
VideoBar(
StreamInfoBar(
streamInfo: streamInfo,
showCategory: showCategory,
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 12),
tooltipTriggerMode: TooltipTriggerMode.longPress,
),
],
),

View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:provider/provider.dart';
/// A tappable card widget that displays an offline followed channel's details.
class OfflineChannelCard extends StatelessWidget {
final FollowedChannel channelInfo;
final bool showPinOption;
final bool? isPinned;
final bool showOfflineStatus;
const OfflineChannelCard({
super.key,
required this.channelInfo,
required this.showPinOption,
this.isPinned,
this.showOfflineStatus = false,
});
@override
Widget build(BuildContext context) {
final channelName = getReadableName(
channelInfo.broadcasterName,
channelInfo.broadcasterLogin,
);
final fontColor = DefaultTextStyle.of(context).style.color;
final followedDuration = DateTime.now().difference(channelInfo.followedAt);
String followedText;
if (followedDuration.inDays >= 365) {
final years = followedDuration.inDays ~/ 365;
followedText = 'Following for $years ${years == 1 ? 'year' : 'years'}';
} else if (followedDuration.inDays >= 30) {
final months = followedDuration.inDays ~/ 30;
followedText =
'Following for $months ${months == 1 ? 'month' : 'months'}';
} else if (followedDuration.inDays > 0) {
followedText =
'Following for ${followedDuration.inDays} ${followedDuration.inDays == 1 ? 'day' : 'days'}';
} else if (followedDuration.inHours > 0) {
followedText =
'Following for ${followedDuration.inHours} ${followedDuration.inHours == 1 ? 'hour' : 'hours'}';
} else {
followedText =
'Following for ${followedDuration.inMinutes} ${followedDuration.inMinutes == 1 ? 'minute' : 'minutes'}';
}
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoChat(
userId: channelInfo.broadcasterId,
userName: channelInfo.broadcasterName,
userLogin: channelInfo.broadcasterLogin,
),
),
),
onLongPress: () {
HapticFeedback.mediumImpact();
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => UserActionsModal(
authStore: context.read<AuthStore>(),
name: channelName,
userLogin: channelInfo.broadcasterLogin,
userId: channelInfo.broadcasterId,
showPinOption: showPinOption,
isPinned: isPinned,
),
);
},
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16 + MediaQuery.of(context).padding.left,
vertical: 8,
),
child: Row(
spacing: 12,
children: [
// Profile picture
ProfilePicture(userLogin: channelInfo.broadcasterLogin),
// Channel info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Channel name
Text(
channelName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: fontColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Status and follow duration
Text(
showOfflineStatus ? 'Offline' : followedText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: fontColor?.withValues(alpha: 0.6),
),
),
],
),
),
],
),
),
);
}
}

View file

@ -8,10 +8,12 @@ import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:intl/intl.dart';
@ -57,16 +59,22 @@ class StreamCard extends StatelessWidget {
'-${thumbnailWidth}x$thumbnailHeight',
),
cacheKey: cacheKey,
placeholder: (context, url) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: const LoadingIndicator(),
placeholder: (context, url) => const SkeletonLoader(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
useOldImageOnUrlChange: true,
),
);
final streamerName =
getReadableName(streamInfo.userName, streamInfo.userLogin);
final streamerName = getReadableName(
streamInfo.userName,
streamInfo.userLogin,
);
final streamTitle = streamInfo.title.trim();
final category = streamInfo.gameName.isNotEmpty
? streamInfo.gameName
: 'No Category';
const subFontSize = 14.0;
@ -81,30 +89,33 @@ class StreamCard extends StatelessWidget {
onLongPress: () => showDialog(
context: context,
builder: (context) => FrostyPhotoViewDialog(
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
'-{width}x{height}',
'',
),
imageUrl: streamInfo
.thumbnailUrl, // Pass original URL with {width}x{height} placeholder
cacheKey: cacheKey,
),
),
child: thumbnail,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.5),
borderRadius: BorderRadius.all(
Radius.circular(6),
),
),
margin: const EdgeInsets.all(4),
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: BlurredContainer(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
sigmaX: 8.0, // Less blur for subtlety
sigmaY: 8.0, // Less blur for subtlety
forceDarkMode: true,
child: Uptime(
startTime: streamInfo.startedAt,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.watch<FrostyThemes>().dark.colorScheme.onSurface,
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
),
),
),
),
@ -116,17 +127,15 @@ class StreamCard extends StatelessWidget {
padding: const EdgeInsets.only(left: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Row(
spacing: 4,
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 10,
),
const SizedBox(width: 4),
ProfilePicture(userLogin: streamInfo.userLogin, radius: 10),
Flexible(
child: Tooltip(
message: 'Streamer: $streamerName',
message: streamerName,
preferBelow: false,
child: Text(
streamerName,
@ -141,12 +150,11 @@ class StreamCard extends StatelessWidget {
),
],
),
const SizedBox(height: 2),
Tooltip(
message: 'Title: ${streamInfo.title.trim()}',
message: streamTitle,
preferBelow: false,
child: Text(
streamInfo.title.trim(),
streamTitle,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: subFontSize,
@ -154,27 +162,22 @@ class StreamCard extends StatelessWidget {
),
),
),
const SizedBox(height: 2),
if (showCategory) ...[
InkWell(
onTap: streamInfo.gameName.isNotEmpty
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryId: streamInfo.gameId,
),
builder: (context) =>
CategoryStreams(categoryId: streamInfo.gameId),
),
)
: null,
child: Tooltip(
message:
'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
message: category,
preferBelow: false,
child: Text(
streamInfo.gameName.isNotEmpty
? streamInfo.gameName
: 'No Category',
category,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: subFontSize,
@ -183,7 +186,6 @@ class StreamCard extends StatelessWidget {
),
),
),
const SizedBox(height: 2),
],
Text(
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
@ -211,7 +213,7 @@ class StreamCard extends StatelessWidget {
onLongPress: () {
HapticFeedback.mediumImpact();
showModalBottomSheet(
showModalBottomSheetWithProperFocus(
context: context,
builder: (context) => UserActionsModal(
authStore: context.read<AuthStore>(),
@ -224,20 +226,20 @@ class StreamCard extends StatelessWidget {
);
},
child: Padding(
padding: EdgeInsets.symmetric(
vertical: 8,
horizontal: showThumbnail ? 16 : 4,
padding: EdgeInsets.only(
top: 8,
bottom: 8,
left: showThumbnail
? 16 + MediaQuery.of(context).padding.left
: 4 + MediaQuery.of(context).padding.left,
right: showThumbnail
? 16 + MediaQuery.of(context).padding.right
: 4 + MediaQuery.of(context).padding.right,
),
child: Row(
children: [
if (showThumbnail)
Flexible(
child: imageSection,
),
Flexible(
flex: 2,
child: streamInfoSection,
),
if (showThumbnail) Flexible(child: imageSection),
Flexible(flex: 2, child: streamInfoSection),
],
),
),

View file

@ -1,8 +1,10 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/models/followed_channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
@ -34,6 +36,9 @@ abstract class ListStoreBase with Store {
/// The pagination cursor for the streams.
String? _streamsCursor;
/// The pagination cursor for the offline followed channels.
String? _offlineChannelsCursor;
/// The last time the streams were refreshed/updated.
var lastTimeRefreshed = DateTime.now();
@ -45,7 +50,8 @@ abstract class ListStoreBase with Store {
bool get isLoading =>
_isAllStreamsLoading ||
_isPinnedStreamsLoading ||
_isCategoryDetailsLoading;
_isCategoryDetailsLoading ||
_isOfflineChannelsLoading;
/// The loading status for pagination.
@ -60,7 +66,7 @@ abstract class ListStoreBase with Store {
var _pinnedStreams = ObservableList<StreamTwitch>();
@readonly
var _isPinnedStreamsLoading = false;
bool _isPinnedStreamsLoading = false;
@readonly
CategoryTwitch? _categoryDetails;
@ -68,10 +74,26 @@ abstract class ListStoreBase with Store {
@readonly
var _isCategoryDetailsLoading = false;
/// The list of offline followed channels.
@readonly
var _allOfflineChannels = ObservableList<FollowedChannel>();
@readonly
bool _isOfflineChannelsLoading = false;
/// Whether or not there are more offline channels for pagination.
@computed
bool get hasMoreOfflineChannels =>
_isOfflineChannelsLoading == false && _offlineChannelsCursor != null;
/// Whether or not the scroll to top button is visible.
@observable
var showJumpButton = false;
/// Whether the offline channels section is expanded.
@observable
var isOfflineChannelsExpanded = false;
/// The list of the fetched streams with blocked users filtered out.
@computed
ObservableList<StreamTwitch> get streams => _allStreams
@ -83,11 +105,46 @@ abstract class ListStoreBase with Store {
.toList()
.asObservable();
/// All pinned channels (only live streams).
@computed
List<dynamic> get allPinnedChannels {
final blockedUserIds = authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.toSet();
// Get live pinned streams only
return _pinnedStreams
.where((stream) => !blockedUserIds.contains(stream.userId))
.toList();
}
/// The list of offline followed channels with blocked users filtered out
/// and excluding channels that are currently live or pinned.
@computed
ObservableList<FollowedChannel> get offlineChannels {
final liveChannelIds = streams.map((stream) => stream.userId).toSet();
final pinnedChannelIds = settingsStore.pinnedChannelIds.toSet();
final blockedUserIds = authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.toSet();
return _allOfflineChannels
.where(
(channel) =>
!liveChannelIds.contains(channel.broadcasterId) &&
!pinnedChannelIds.contains(channel.broadcasterId) &&
!blockedUserIds.contains(channel.broadcasterId),
)
.toList()
.asObservable();
}
/// The error message to show if any. Will be non-null if there is an error.
@readonly
String? _error;
ReactionDisposer? _pinnedStreamsReactioniDisposer;
ReactionDisposer? _offlineChannelsExpansionDisposer;
ListStoreBase({
required this.authStore,
@ -115,6 +172,23 @@ abstract class ListStoreBase with Store {
);
getPinnedStreams();
// Fetch offline channels only when expanded
_offlineChannelsExpansionDisposer = reaction(
(_) => isOfflineChannelsExpanded,
(expanded) {
if (expanded) {
// Fetch offline channels when expanded (only if not already loaded)
if (_allOfflineChannels.isEmpty) {
getOfflineChannels();
}
} else {
// Clear offline channels when collapsed to save memory
_allOfflineChannels.clear();
_offlineChannelsCursor = null;
}
},
);
}
if (listType == ListType.category) {
@ -135,20 +209,15 @@ abstract class ListStoreBase with Store {
case ListType.followed:
newStreams = await twitchApi.getFollowedStreams(
id: authStore.user.details!.id,
headers: authStore.headersTwitch,
cursor: _streamsCursor,
);
break;
case ListType.top:
newStreams = await twitchApi.getTopStreams(
headers: authStore.headersTwitch,
cursor: _streamsCursor,
);
newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor);
break;
case ListType.category:
newStreams = await twitchApi.getStreamsUnderCategory(
gameId: categoryId!,
headers: authStore.headersTwitch,
cursor: _streamsCursor,
);
break;
@ -163,9 +232,14 @@ abstract class ListStoreBase with Store {
_error = null;
} on SocketException {
_error = 'Failed to connect';
_error = 'Unable to connect to Twitch';
debugPrint('Streams SocketException: No internet connection');
} on ApiException catch (e) {
_error = e.message;
debugPrint('Streams ApiException: $e');
} catch (e) {
_error = e.toString();
_error = 'Something went wrong loading streams';
debugPrint('Streams error: $e');
}
_isAllStreamsLoading = false;
@ -183,25 +257,67 @@ abstract class ListStoreBase with Store {
try {
_pinnedStreams = (await twitchApi.getStreamsByIds(
userIds: settingsStore.pinnedChannelIds,
headers: authStore.headersTwitch,
))
.data
.asObservable();
)).data.asObservable();
_error = null;
} on SocketException {
_error = 'Failed to connect';
_error = 'Unable to connect to Twitch';
debugPrint('Pinned streams SocketException: No internet connection');
} on ApiException catch (e) {
_error = e.message;
debugPrint('Pinned streams ApiException: $e');
} catch (e) {
_error = e.toString();
_error = 'Something went wrong loading pinned streams';
debugPrint('Pinned streams error: $e');
}
_isPinnedStreamsLoading = false;
}
@action
Future<void> getOfflineChannels() async {
_isOfflineChannelsLoading = true;
try {
final followedChannels = await twitchApi.getFollowedChannels(
userId: authStore.user.details!.id,
cursor: _offlineChannelsCursor,
);
if (_offlineChannelsCursor == null) {
_allOfflineChannels = followedChannels.data.asObservable();
} else {
_allOfflineChannels.addAll(followedChannels.data);
}
_offlineChannelsCursor = followedChannels.pagination['cursor'];
_error = null;
} on SocketException {
_error = 'Unable to connect to Twitch';
debugPrint('Offline channels SocketException: No internet connection');
} on ApiException catch (e) {
_error = e.message;
debugPrint('Offline channels ApiException: $e');
} catch (e) {
_error = 'Something went wrong loading offline channels';
debugPrint('Offline channels error: $e');
}
_isOfflineChannelsLoading = false;
}
/// Resets the cursor and then fetches the streams.
@action
Future<void> refreshStreams() async {
if (listType == ListType.followed) await getPinnedStreams();
if (listType == ListType.followed) {
await getPinnedStreams();
// Only refresh offline channels if they are currently expanded
if (isOfflineChannelsExpanded) {
_offlineChannelsCursor = null;
await getOfflineChannels();
}
}
_streamsCursor = null;
await getStreams();
@ -213,10 +329,7 @@ abstract class ListStoreBase with Store {
_isCategoryDetailsLoading = true;
final categoryDetails = await twitchApi.getCategory(
headers: authStore.headersTwitch,
gameId: categoryId!,
);
final categoryDetails = await twitchApi.getCategory(gameId: categoryId!);
_categoryDetails = categoryDetails.data.first;
@ -235,6 +348,7 @@ abstract class ListStoreBase with Store {
void dispose() {
_pinnedStreamsReactioniDisposer?.call();
_offlineChannelsExpansionDisposer?.call();
scrollController?.dispose();
}
@ -245,8 +359,4 @@ abstract class ListStoreBase with Store {
/// [ListType.followed] is the list of streams that the user is following.
/// [ListType.top] is the list of top streams.
/// [ListType.category] is the list of streams under a category.
enum ListType {
followed,
top,
category,
}
enum ListType { followed, top, category }

View file

@ -12,26 +12,54 @@ mixin _$ListStore on ListStoreBase, Store {
Computed<bool>? _$hasMoreComputed;
@override
bool get hasMore => (_$hasMoreComputed ??=
Computed<bool>(() => super.hasMore, name: 'ListStoreBase.hasMore'))
.value;
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(
() => super.hasMore,
name: 'ListStoreBase.hasMore',
)).value;
Computed<bool>? _$isLoadingComputed;
@override
bool get isLoading =>
(_$isLoadingComputed ??= Computed<bool>(() => super.isLoading,
name: 'ListStoreBase.isLoading'))
.value;
bool get isLoading => (_$isLoadingComputed ??= Computed<bool>(
() => super.isLoading,
name: 'ListStoreBase.isLoading',
)).value;
Computed<bool>? _$hasMoreOfflineChannelsComputed;
@override
bool get hasMoreOfflineChannels =>
(_$hasMoreOfflineChannelsComputed ??= Computed<bool>(
() => super.hasMoreOfflineChannels,
name: 'ListStoreBase.hasMoreOfflineChannels',
)).value;
Computed<ObservableList<StreamTwitch>>? _$streamsComputed;
@override
ObservableList<StreamTwitch> get streams => (_$streamsComputed ??=
Computed<ObservableList<StreamTwitch>>(() => super.streams,
name: 'ListStoreBase.streams'))
.value;
ObservableList<StreamTwitch> get streams =>
(_$streamsComputed ??= Computed<ObservableList<StreamTwitch>>(
() => super.streams,
name: 'ListStoreBase.streams',
)).value;
Computed<List<dynamic>>? _$allPinnedChannelsComputed;
late final _$_allStreamsAtom =
Atom(name: 'ListStoreBase._allStreams', context: context);
@override
List<dynamic> get allPinnedChannels =>
(_$allPinnedChannelsComputed ??= Computed<List<dynamic>>(
() => super.allPinnedChannels,
name: 'ListStoreBase.allPinnedChannels',
)).value;
Computed<ObservableList<FollowedChannel>>? _$offlineChannelsComputed;
@override
ObservableList<FollowedChannel> get offlineChannels =>
(_$offlineChannelsComputed ??= Computed<ObservableList<FollowedChannel>>(
() => super.offlineChannels,
name: 'ListStoreBase.offlineChannels',
)).value;
late final _$_allStreamsAtom = Atom(
name: 'ListStoreBase._allStreams',
context: context,
);
ObservableList<StreamTwitch> get allStreams {
_$_allStreamsAtom.reportRead();
@ -48,8 +76,10 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_isAllStreamsLoadingAtom =
Atom(name: 'ListStoreBase._isAllStreamsLoading', context: context);
late final _$_isAllStreamsLoadingAtom = Atom(
name: 'ListStoreBase._isAllStreamsLoading',
context: context,
);
bool get isAllStreamsLoading {
_$_isAllStreamsLoadingAtom.reportRead();
@ -61,14 +91,19 @@ mixin _$ListStore on ListStoreBase, Store {
@override
set _isAllStreamsLoading(bool value) {
_$_isAllStreamsLoadingAtom.reportWrite(value, super._isAllStreamsLoading,
_$_isAllStreamsLoadingAtom.reportWrite(
value,
super._isAllStreamsLoading,
() {
super._isAllStreamsLoading = value;
});
},
);
}
late final _$_pinnedStreamsAtom =
Atom(name: 'ListStoreBase._pinnedStreams', context: context);
late final _$_pinnedStreamsAtom = Atom(
name: 'ListStoreBase._pinnedStreams',
context: context,
);
ObservableList<StreamTwitch> get pinnedStreams {
_$_pinnedStreamsAtom.reportRead();
@ -85,8 +120,10 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_isPinnedStreamsLoadingAtom =
Atom(name: 'ListStoreBase._isPinnedStreamsLoading', context: context);
late final _$_isPinnedStreamsLoadingAtom = Atom(
name: 'ListStoreBase._isPinnedStreamsLoading',
context: context,
);
bool get isPinnedStreamsLoading {
_$_isPinnedStreamsLoadingAtom.reportRead();
@ -98,14 +135,19 @@ mixin _$ListStore on ListStoreBase, Store {
@override
set _isPinnedStreamsLoading(bool value) {
_$_isPinnedStreamsLoadingAtom
.reportWrite(value, super._isPinnedStreamsLoading, () {
_$_isPinnedStreamsLoadingAtom.reportWrite(
value,
super._isPinnedStreamsLoading,
() {
super._isPinnedStreamsLoading = value;
});
},
);
}
late final _$_categoryDetailsAtom =
Atom(name: 'ListStoreBase._categoryDetails', context: context);
late final _$_categoryDetailsAtom = Atom(
name: 'ListStoreBase._categoryDetails',
context: context,
);
CategoryTwitch? get categoryDetails {
_$_categoryDetailsAtom.reportRead();
@ -122,8 +164,10 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_isCategoryDetailsLoadingAtom =
Atom(name: 'ListStoreBase._isCategoryDetailsLoading', context: context);
late final _$_isCategoryDetailsLoadingAtom = Atom(
name: 'ListStoreBase._isCategoryDetailsLoading',
context: context,
);
bool get isCategoryDetailsLoading {
_$_isCategoryDetailsLoadingAtom.reportRead();
@ -135,14 +179,63 @@ mixin _$ListStore on ListStoreBase, Store {
@override
set _isCategoryDetailsLoading(bool value) {
_$_isCategoryDetailsLoadingAtom
.reportWrite(value, super._isCategoryDetailsLoading, () {
_$_isCategoryDetailsLoadingAtom.reportWrite(
value,
super._isCategoryDetailsLoading,
() {
super._isCategoryDetailsLoading = value;
},
);
}
late final _$_allOfflineChannelsAtom = Atom(
name: 'ListStoreBase._allOfflineChannels',
context: context,
);
ObservableList<FollowedChannel> get allOfflineChannels {
_$_allOfflineChannelsAtom.reportRead();
return super._allOfflineChannels;
}
@override
ObservableList<FollowedChannel> get _allOfflineChannels => allOfflineChannels;
@override
set _allOfflineChannels(ObservableList<FollowedChannel> value) {
_$_allOfflineChannelsAtom.reportWrite(value, super._allOfflineChannels, () {
super._allOfflineChannels = value;
});
}
late final _$showJumpButtonAtom =
Atom(name: 'ListStoreBase.showJumpButton', context: context);
late final _$_isOfflineChannelsLoadingAtom = Atom(
name: 'ListStoreBase._isOfflineChannelsLoading',
context: context,
);
bool get isOfflineChannelsLoading {
_$_isOfflineChannelsLoadingAtom.reportRead();
return super._isOfflineChannelsLoading;
}
@override
bool get _isOfflineChannelsLoading => isOfflineChannelsLoading;
@override
set _isOfflineChannelsLoading(bool value) {
_$_isOfflineChannelsLoadingAtom.reportWrite(
value,
super._isOfflineChannelsLoading,
() {
super._isOfflineChannelsLoading = value;
},
);
}
late final _$showJumpButtonAtom = Atom(
name: 'ListStoreBase.showJumpButton',
context: context,
);
@override
bool get showJumpButton {
@ -157,8 +250,32 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_errorAtom =
Atom(name: 'ListStoreBase._error', context: context);
late final _$isOfflineChannelsExpandedAtom = Atom(
name: 'ListStoreBase.isOfflineChannelsExpanded',
context: context,
);
@override
bool get isOfflineChannelsExpanded {
_$isOfflineChannelsExpandedAtom.reportRead();
return super.isOfflineChannelsExpanded;
}
@override
set isOfflineChannelsExpanded(bool value) {
_$isOfflineChannelsExpandedAtom.reportWrite(
value,
super.isOfflineChannelsExpanded,
() {
super.isOfflineChannelsExpanded = value;
},
);
}
late final _$_errorAtom = Atom(
name: 'ListStoreBase._error',
context: context,
);
String? get error {
_$_errorAtom.reportRead();
@ -175,46 +292,71 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$getStreamsAsyncAction =
AsyncAction('ListStoreBase.getStreams', context: context);
late final _$getStreamsAsyncAction = AsyncAction(
'ListStoreBase.getStreams',
context: context,
);
@override
Future<void> getStreams() {
return _$getStreamsAsyncAction.run(() => super.getStreams());
}
late final _$getPinnedStreamsAsyncAction =
AsyncAction('ListStoreBase.getPinnedStreams', context: context);
late final _$getPinnedStreamsAsyncAction = AsyncAction(
'ListStoreBase.getPinnedStreams',
context: context,
);
@override
Future<void> getPinnedStreams() {
return _$getPinnedStreamsAsyncAction.run(() => super.getPinnedStreams());
}
late final _$refreshStreamsAsyncAction =
AsyncAction('ListStoreBase.refreshStreams', context: context);
late final _$getOfflineChannelsAsyncAction = AsyncAction(
'ListStoreBase.getOfflineChannels',
context: context,
);
@override
Future<void> getOfflineChannels() {
return _$getOfflineChannelsAsyncAction.run(
() => super.getOfflineChannels(),
);
}
late final _$refreshStreamsAsyncAction = AsyncAction(
'ListStoreBase.refreshStreams',
context: context,
);
@override
Future<void> refreshStreams() {
return _$refreshStreamsAsyncAction.run(() => super.refreshStreams());
}
late final _$_getCategoryDetailsAsyncAction =
AsyncAction('ListStoreBase._getCategoryDetails', context: context);
late final _$_getCategoryDetailsAsyncAction = AsyncAction(
'ListStoreBase._getCategoryDetails',
context: context,
);
@override
Future<void> _getCategoryDetails() {
return _$_getCategoryDetailsAsyncAction
.run(() => super._getCategoryDetails());
return _$_getCategoryDetailsAsyncAction.run(
() => super._getCategoryDetails(),
);
}
@override
String toString() {
return '''
showJumpButton: ${showJumpButton},
isOfflineChannelsExpanded: ${isOfflineChannelsExpanded},
hasMore: ${hasMore},
isLoading: ${isLoading},
streams: ${streams}
hasMoreOfflineChannels: ${hasMoreOfflineChannels},
streams: ${streams},
allPinnedChannels: ${allPinnedChannels},
offlineChannels: ${offlineChannels}
''';
}
}

View file

@ -2,17 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/offline_channel_card.dart';
import 'package:frosty/screens/home/stream_list/stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/expandable_section_header.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of followed or top streams based on the provided [listType].
@ -89,7 +91,16 @@ class _StreamsListState extends State<StreamsList>
_listStore.checkLastTimeRefreshedAndUpdate();
final extraTopPadding = widget.listType == ListType.top
? kToolbarHeight
: widget.listType == ListType.category
? kToolbarHeight + 122
: 0.0;
final topPadding = MediaQuery.of(context).padding.top + extraTopPadding;
return RefreshIndicator.adaptive(
edgeOffset: topPadding,
onRefresh: () async {
HapticFeedback.lightImpact();
@ -97,10 +108,7 @@ class _StreamsListState extends State<StreamsList>
if (_listStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(
message: _listStore.error!,
centered: false,
),
content: AlertMessage(message: _listStore.error!, centered: false),
);
if (!context.mounted) return;
@ -113,18 +121,98 @@ class _StreamsListState extends State<StreamsList>
Widget? statusWidget;
if (_listStore.error != null) {
statusWidget = AlertMessage(message: _listStore.error!);
statusWidget = AlertMessage(
message: _listStore.error!,
vertical: true,
);
}
if (_listStore.streams.isEmpty) {
if (_listStore.isLoading && _listStore.error == null) {
statusWidget =
const LoadingIndicator(subtitle: 'Loading streams...');
// Show skeleton loaders while loading
final settingsStore = context.watch<SettingsStore>();
final isLargeCard = settingsStore.largeStreamCard;
final isFollowingTab = widget.listType == ListType.followed;
final pinnedChannelCount = settingsStore.pinnedChannelIds.length;
return CustomScrollView(
slivers: [
// Add padding for app bar
SliverTopPadding(extraTopPadding: extraTopPadding),
// Show pinned section if following tab and has pinned channels
if (isFollowingTab && pinnedChannelCount > 0) ...[
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Pinned',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
SliverList.builder(
itemCount: pinnedChannelCount,
itemBuilder: (context, index) {
if (isLargeCard) {
return LargeStreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
} else {
return StreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
}
},
),
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'All',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
],
SliverList.builder(
itemCount: isFollowingTab && pinnedChannelCount > 0 ? 5 : 8,
itemBuilder: (context, index) {
if (isLargeCard) {
return LargeStreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
} else {
return StreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
}
},
),
SliverBottomPadding(),
],
);
} else {
statusWidget = AlertMessage(
message: widget.listType == ListType.followed
? 'No followed streams'
: 'No top streams',
vertical: true,
);
}
}
@ -132,23 +220,22 @@ class _StreamsListState extends State<StreamsList>
if (statusWidget != null) {
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: statusWidget,
),
),
SliverFillRemaining(child: Center(child: statusWidget)),
],
);
}
final settingsStore = context.watch<SettingsStore>();
final unpinnedStreams = _listStore.streams
final unpinnedStreams = widget.listType == ListType.followed
? _listStore.streams
.where(
(stream) =>
!settingsStore.pinnedChannelIds.contains(stream.userId),
(stream) => !settingsStore.pinnedChannelIds.contains(
stream.userId,
),
)
.toList();
.toList()
: _listStore.streams;
final isFollowingTab = widget.listType == ListType.followed;
@ -157,50 +244,53 @@ class _StreamsListState extends State<StreamsList>
children: [
Column(
children: [
if (widget.categoryId != null &&
_listStore.categoryDetails != null)
CategoryCard(
category: _listStore.categoryDetails!,
isTappable: false,
),
if (_listStore.scrollController != null)
AnimatedScrollBorder(
scrollController: _listStore.scrollController!,
),
Expanded(
child: Scrollbar(
child: FrostyScrollbar(
controller: _listStore.scrollController,
padding: EdgeInsets.only(
top:
MediaQuery.of(context).padding.top +
extraTopPadding,
bottom: MediaQuery.of(context).padding.bottom,
),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: _listStore.scrollController,
slivers: [
SliverTopPadding(extraTopPadding: extraTopPadding),
if (isFollowingTab &&
_listStore.pinnedStreams.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader(
_listStore.allPinnedChannels.isNotEmpty) ...[
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Pinned',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16,
12,
16,
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
SliverList.builder(
itemCount: _listStore.pinnedStreams.length,
),
Observer(
builder: (context) {
// Show actual pinned channels (live streams only)
return SliverList.builder(
itemCount:
_listStore.allPinnedChannels.length,
itemBuilder: (context, index) {
final stream =
_listStore.allPinnedChannels[index]
as StreamTwitch;
return Observer(
builder: (context) {
// Only live stream cards since we removed offline pinned channels
return settingsStore.largeStreamCard
? LargeStreamCard(
key: ValueKey(
_listStore
.pinnedStreams[index].userId,
),
streamInfo:
_listStore.pinnedStreams[index],
key: ValueKey(stream.userId),
streamInfo: stream,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
@ -210,12 +300,8 @@ class _StreamsListState extends State<StreamsList>
isPinned: true,
)
: StreamCard(
key: ValueKey(
_listStore
.pinnedStreams[index].userId,
),
streamInfo:
_listStore.pinnedStreams[index],
key: ValueKey(stream.userId),
streamInfo: stream,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
@ -227,19 +313,23 @@ class _StreamsListState extends State<StreamsList>
},
);
},
);
},
),
const SliverToBoxAdapter(
child: SectionHeader(
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'All',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16,
16 + MediaQuery.of(context).padding.left,
8,
16,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
],
SliverList.builder(
itemCount: unpinnedStreams.length,
@ -249,8 +339,8 @@ class _StreamsListState extends State<StreamsList>
_listStore.getStreams();
}
return Observer(
builder: (context) => settingsStore
.largeStreamCard
builder: (context) =>
settingsStore.largeStreamCard
? LargeStreamCard(
key: ValueKey(
unpinnedStreams[index].userId,
@ -276,6 +366,57 @@ class _StreamsListState extends State<StreamsList>
);
},
),
// Add offline followed channels section for following tab
if (isFollowingTab) ...[
SliverToBoxAdapter(
child: Observer(
builder: (context) => ExpandableSectionHeader(
'Offline',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
16,
16 + MediaQuery.of(context).padding.right,
8,
),
isExpanded:
_listStore.isOfflineChannelsExpanded,
onToggle: () =>
_listStore.isOfflineChannelsExpanded =
!_listStore.isOfflineChannelsExpanded,
),
),
),
if (_listStore.isOfflineChannelsExpanded)
SliverList.builder(
itemCount: _listStore.offlineChannels.length,
itemBuilder: (context, index) {
// Load more offline channels when nearing the end
if (index >
_listStore.offlineChannels.length -
5 &&
_listStore.hasMoreOfflineChannels) {
_listStore.getOfflineChannels();
}
return Observer(
builder: (context) => OfflineChannelCard(
key: ValueKey(
_listStore
.offlineChannels[index]
.broadcasterId,
),
channelInfo:
_listStore.offlineChannels[index],
showPinOption: false,
isPinned: false,
),
);
},
),
],
// Add padding for bottom navigation bar
const SliverBottomPadding(),
],
),
),
@ -302,3 +443,31 @@ class _StreamsListState extends State<StreamsList>
);
}
}
/// Helper widget for consistent top padding in sliver lists
class SliverTopPadding extends StatelessWidget {
final double extraTopPadding;
const SliverTopPadding({super.key, this.extraTopPadding = 0.0});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).padding.top + extraTopPadding,
),
);
}
}
/// Helper widget for consistent bottom padding in sliver lists
class SliverBottomPadding extends StatelessWidget {
const SliverBottomPadding({super.key});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
);
}
}

View file

@ -2,21 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/home/top/categories/categories_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:provider/provider.dart';
class Categories extends StatefulWidget {
final ScrollController scrollController;
const Categories({
super.key,
required this.scrollController,
});
const Categories({super.key, required this.scrollController});
@override
State<Categories> createState() => _CategoriesState();
@ -53,7 +51,10 @@ class _CategoriesState extends State<Categories>
_categoriesStore.checkLastTimeRefreshedAndUpdate();
final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight;
return RefreshIndicator.adaptive(
edgeOffset: topPadding,
onRefresh: () async {
HapticFeedback.lightImpact();
await _categoriesStore.refreshCategories();
@ -76,41 +77,55 @@ class _CategoriesState extends State<Categories>
Widget? statusWidget;
if (_categoriesStore.error != null) {
statusWidget = AlertMessage(message: _categoriesStore.error!);
statusWidget = AlertMessage(
message: _categoriesStore.error!,
vertical: true,
);
}
if (_categoriesStore.categories.isEmpty) {
if (_categoriesStore.isLoading && _categoriesStore.error == null) {
statusWidget =
const LoadingIndicator(subtitle: 'Loading categories...');
// Show skeleton loaders while loading
return CustomScrollView(
controller: widget.scrollController,
slivers: [
const SliverTopPadding(extraTopPadding: kToolbarHeight),
SliverList.builder(
itemCount: 10,
itemBuilder: (context, index) =>
const CategorySkeletonLoader(),
),
const SliverBottomPadding(),
],
);
} else {
statusWidget = const AlertMessage(message: 'No top categories');
statusWidget = const AlertMessage(
message: 'No top categories',
vertical: true,
);
}
}
if (statusWidget != null) {
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: statusWidget,
),
),
SliverFillRemaining(child: Center(child: statusWidget)),
],
);
}
return Column(
children: [
AnimatedScrollBorder(
scrollController: widget.scrollController,
),
Expanded(
child: Scrollbar(
return FrostyScrollbar(
controller: widget.scrollController,
child: ListView.builder(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + kToolbarHeight,
bottom: 64 + MediaQuery.of(context).padding.bottom,
),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
slivers: [
const SliverTopPadding(extraTopPadding: kToolbarHeight),
SliverList.builder(
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length - 10 &&
@ -123,9 +138,9 @@ class _CategoriesState extends State<Categories>
);
},
),
),
),
const SliverBottomPadding(),
],
),
);
},
),

View file

@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
@ -49,7 +51,6 @@ abstract class CategoriesStoreBase with Store {
try {
final result = await twitchApi.getTopCategories(
headers: authStore.headersTwitch,
cursor: _categoriesCursor,
);
@ -62,9 +63,14 @@ abstract class CategoriesStoreBase with Store {
_error = null;
} on SocketException {
_error = 'Failed to connect';
_error = 'Unable to connect to Twitch';
debugPrint('Categories SocketException: No internet connection');
} on ApiException catch (e) {
_error = e.message;
debugPrint('Categories ApiException: $e');
} catch (e) {
_error = e.toString();
_error = 'Something went wrong loading categories';
debugPrint('Categories error: $e');
}
_isLoading = false;

View file

@ -12,12 +12,15 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
Computed<bool>? _$hasMoreComputed;
@override
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(() => super.hasMore,
name: 'CategoriesStoreBase.hasMore'))
.value;
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(
() => super.hasMore,
name: 'CategoriesStoreBase.hasMore',
)).value;
late final _$_isLoadingAtom =
Atom(name: 'CategoriesStoreBase._isLoading', context: context);
late final _$_isLoadingAtom = Atom(
name: 'CategoriesStoreBase._isLoading',
context: context,
);
bool get isLoading {
_$_isLoadingAtom.reportRead();
@ -34,8 +37,10 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
});
}
late final _$_categoriesAtom =
Atom(name: 'CategoriesStoreBase._categories', context: context);
late final _$_categoriesAtom = Atom(
name: 'CategoriesStoreBase._categories',
context: context,
);
ObservableList<CategoryTwitch> get categories {
_$_categoriesAtom.reportRead();
@ -52,8 +57,10 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
});
}
late final _$_errorAtom =
Atom(name: 'CategoriesStoreBase._error', context: context);
late final _$_errorAtom = Atom(
name: 'CategoriesStoreBase._error',
context: context,
);
String? get error {
_$_errorAtom.reportRead();
@ -70,21 +77,26 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
});
}
late final _$getCategoriesAsyncAction =
AsyncAction('CategoriesStoreBase.getCategories', context: context);
late final _$getCategoriesAsyncAction = AsyncAction(
'CategoriesStoreBase.getCategories',
context: context,
);
@override
Future<void> getCategories() {
return _$getCategoriesAsyncAction.run(() => super.getCategories());
}
late final _$CategoriesStoreBaseActionController =
ActionController(name: 'CategoriesStoreBase', context: context);
late final _$CategoriesStoreBaseActionController = ActionController(
name: 'CategoriesStoreBase',
context: context,
);
@override
Future<void> refreshCategories() {
final _$actionInfo = _$CategoriesStoreBaseActionController.startAction(
name: 'CategoriesStoreBase.refreshCategories');
name: 'CategoriesStoreBase.refreshCategories',
);
try {
return super.refreshCategories();
} finally {

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
/// A tappable card widget that displays a category's box art and name under.
class CategoryCard extends StatelessWidget {
@ -28,15 +28,19 @@ class CategoryCard extends StatelessWidget {
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryId: category.id,
),
builder: (context) => CategoryStreams(categoryId: category.id),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: EdgeInsets.only(
top: 8,
bottom: 8,
left: 16 + MediaQuery.of(context).padding.left,
right: 16 + MediaQuery.of(context).padding.right,
),
child: Row(
spacing: 16,
children: [
SizedBox(
width: 80,
@ -50,22 +54,19 @@ class CategoryCard extends StatelessWidget {
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: const LoadingIndicator(),
placeholder: (context, url) => const SkeletonLoader(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
),
const SizedBox(width: 16),
Flexible(
child: Text(
category.name,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
],

View file

@ -1,32 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of streams under the provided [categoryId].
class CategoryStreams extends StatelessWidget {
class CategoryStreams extends StatefulWidget {
/// The category id, used for fetching the relevant streams in the [ListStore].
final String categoryId;
const CategoryStreams({
super.key,
required this.categoryId,
});
const CategoryStreams({super.key, required this.categoryId});
@override
State<CategoryStreams> createState() => _CategoryStreamsState();
}
class _CategoryStreamsState extends State<CategoryStreams> {
late final _listStore = ListStore(
listType: ListType.category,
categoryId: widget.categoryId,
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
settingsStore: context.read<SettingsStore>(),
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
leading: IconButton(
backgroundColor: theme.scaffoldBackgroundColor,
extendBody: true,
extendBodyBehindAppBar: true,
body: Stack(
children: [
// Stream list content behind the pinned elements
Positioned.fill(
child: StreamsList(
listType: ListType.category,
categoryId: widget.categoryId,
showJumpButton: true,
),
),
// Single blurred background spanning app bar and category card
Positioned(
top: 0,
left: 0,
right: 0,
child: BlurredContainer(
gradientDirection: GradientDirection.up,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
width: 0.5,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// App bar section
SizedBox(
height: kToolbarHeight,
child: Row(
children: [
IconButton(
tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
shape: const Border(),
],
),
body: StreamsList(
listType: ListType.category,
categoryId: categoryId,
showJumpButton: true,
),
// Category card section
Observer(
builder: (_) {
if (_listStore.categoryDetails != null) {
return _TransparentCategoryCard(
category: _listStore.categoryDetails!,
);
} else {
// Skeleton loader for category card
return Padding(
padding: const EdgeInsets.only(
bottom: 16,
left: 16,
right: 16,
),
child: Row(
spacing: 12,
children: [
const SizedBox(
width: 80,
child: AspectRatio(
aspectRatio: 3 / 4,
child: SkeletonLoader(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
),
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
spacing: 8,
children: [
SkeletonLoader(
height: 20,
width: double.infinity,
borderRadius: BorderRadius.circular(4),
),
SkeletonLoader(
height: 16,
width: 120,
borderRadius: BorderRadius.circular(4),
),
],
),
),
],
),
);
}
},
),
],
),
),
),
),
],
),
);
}
@override
void dispose() {
_listStore.dispose();
super.dispose();
}
}
/// A transparent version of CategoryCard for use in blurred overlays
class _TransparentCategoryCard extends StatelessWidget {
final dynamic category;
const _TransparentCategoryCard({required this.category});
@override
Widget build(BuildContext context) {
// Calculate the dimensions of the box art based on the current dimensions of the screen.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio) ~/ 5;
final artHeight = (artWidth * (4 / 3)).toInt();
return Padding(
padding: const EdgeInsets.only(bottom: 16, left: 16, right: 16),
child: Row(
spacing: 12,
children: [
SizedBox(
width: 80,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 3 / 4,
child: FrostyCachedNetworkImage(
imageUrl: category.boxArtUrl.replaceRange(
category.boxArtUrl.lastIndexOf('-') + 1,
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => const SkeletonLoader(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
fit: BoxFit.cover,
),
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
category.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}

View file

@ -3,14 +3,12 @@ import 'package:frosty/screens/home/home_store.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/home/top/categories/categories.dart';
import 'package:frosty/widgets/blurred_container.dart';
class TopSection extends StatefulWidget {
final HomeStore homeStore;
const TopSection({
super.key,
required this.homeStore,
});
const TopSection({super.key, required this.homeStore});
@override
State<TopSection> createState() => _TopSectionState();
@ -30,20 +28,9 @@ class _TopSectionState extends State<TopSection>
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Stack(
children: [
TabBar(
isScrollable: true,
controller: _tabBarController,
tabs: const [
Tab(
child: Text('Streams'),
),
Tab(child: Text('Categories')),
],
),
Expanded(
Positioned.fill(
child: TabBarView(
controller: _tabBarController,
children: [
@ -59,6 +46,27 @@ class _TopSectionState extends State<TopSection>
],
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: BlurredContainer(
gradientDirection: GradientDirection.up,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: TabBar(
isScrollable: true,
controller: _tabBarController,
tabs: const [
Tab(child: Text('Streams')),
Tab(child: Text('Categories')),
],
),
),
),
],
);
}

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/frosty_app_bar.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Reusable WebView widget for Twitch OAuth login flow
class LoginWebView extends StatelessWidget {
/// Optional widget to navigate to after successful login
final Widget? routeAfter;
const LoginWebView({super.key, this.routeAfter});
@override
Widget build(BuildContext context) {
final authStore = context.read<AuthStore>();
return Scaffold(
appBar: FrostyAppBar(
title: const Text('Connect with Twitch'),
actions: [
IconButton(
icon: const Icon(Icons.help_rounded),
onPressed: () => showDialog(
context: context,
builder: (context) {
return FrostyDialog(
title: 'Workaround for the Twitch cookie banner',
message:
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.',
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Close'),
),
],
);
},
),
),
],
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(
routeAfter: routeAfter,
),
),
);
}
}

View file

@ -1,19 +1,14 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
import 'package:frosty/screens/onboarding/onboarding_setup.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:provider/provider.dart';
import 'package:simple_icons/simple_icons.dart';
import 'package:webview_flutter/webview_flutter.dart';
class OnboardingLogin extends StatelessWidget {
const OnboardingLogin({super.key});
@override
Widget build(BuildContext context) {
final authStore = context.read<AuthStore>();
return OnboardingScaffold(
header: 'Log in',
subtitle:
@ -23,39 +18,7 @@ class OnboardingLogin extends StatelessWidget {
buttonText: 'Connect with Twitch',
buttonIcon: const Icon(SimpleIcons.twitch),
skipRoute: const OnboardingSetup(),
route: Scaffold(
appBar: FrostyAppBar(
title: const Text('Connect with Twitch'),
actions: [
IconButton(
icon: const Icon(Icons.help_rounded),
onPressed: () => showDialog(
context: context,
builder: (context) {
return AlertDialog(
title:
const Text('Workaround for the Twitch cookie banner'),
content: const Text(
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.',
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Close'),
),
],
);
},
),
),
],
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(
routeAfter: const OnboardingSetup(),
),
),
),
route: LoginWebView(routeAfter: const OnboardingSetup()),
);
}
}

View file

@ -37,14 +37,13 @@ class OnboardingScaffold extends StatelessWidget {
]);
if (isLast) {
SharedPreferences.getInstance()
.then((prefs) => prefs.setBool('first_run', false));
SharedPreferences.getInstance().then(
(prefs) => prefs.setBool('first_run', false),
);
}
return Scaffold(
appBar: AppBar(
shape: const Border(),
),
appBar: AppBar(shape: const Border()),
body: SafeArea(
child: SizedBox(
width: double.infinity,
@ -53,16 +52,14 @@ class OnboardingScaffold extends StatelessWidget {
Padding(
padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
child: Column(
spacing: 24,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12,
children: [
if (showLogo) ...[
SvgPicture.asset(
'assets/icons/logo.svg',
height: 80,
),
const SizedBox(width: 12),
SvgPicture.asset('assets/icons/logo.svg', height: 80),
],
Text(
header,
@ -74,7 +71,6 @@ class OnboardingScaffold extends StatelessWidget {
],
),
if (subtitle != null) ...[
const SizedBox(height: 24),
Opacity(
opacity: 0.8,
child: Text(
@ -97,8 +93,10 @@ class OnboardingScaffold extends StatelessWidget {
),
if (disclaimer != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Opacity(
opacity: 0.5,
child: Text(
@ -120,9 +118,7 @@ class OnboardingScaffold extends StatelessWidget {
)
: Navigator.push(
context,
MaterialPageRoute(
builder: (context) => route,
),
MaterialPageRoute(builder: (context) => route),
),
icon: buttonIcon ?? const SizedBox(),
label: Text(buttonText ?? 'Next'),
@ -135,9 +131,7 @@ class OnboardingScaffold extends StatelessWidget {
child: TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => skipRoute!,
),
MaterialPageRoute(builder: (context) => skipRoute!),
),
child: const Text('Skip'),
),

View file

@ -3,14 +3,14 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
// import removed: flutter_colorpicker
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
import 'package:frosty/screens/onboarding/onboarding_welcome.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/accent_color_setting.dart';
import 'package:frosty/widgets/theme_selection_setting.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
@ -29,60 +29,8 @@ class OnboardingSetup extends StatelessWidget {
builder: (context) {
return ListView(
children: [
SettingsListSelect(
title: 'Theme',
selectedOption: themeNames[settingsStore.themeType.index],
options: themeNames,
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
ListTile(
title: const Text('Accent color'),
trailing: IconButton(
icon: DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 2,
),
),
child: CircleAvatar(
backgroundColor: Color(settingsStore.accentColor),
radius: 16,
),
),
onPressed: () {
showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Accent color',
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: Color(settingsStore.accentColor),
onColorChanged: (newColor) =>
// TODO: Update when new method arrives in stable:
// https://github.com/flutter/flutter/issues/160184#issuecomment-2560184639
// ignore: deprecated_member_use
settingsStore.accentColor = newColor.value,
enableAlpha: false,
pickerAreaBorderRadius:
const BorderRadius.all(Radius.circular(8)),
labelTypes: const [],
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
],
),
);
},
),
),
ThemeSelectionSetting(settingsStore: settingsStore),
AccentColorSetting(settingsStore: settingsStore),
SettingsListSwitch(
title: 'Show historical recent messages',
subtitle: Text.rich(
@ -99,9 +47,7 @@ class OnboardingSetup extends StatelessWidget {
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse(
'https://recent-messages.robotty.de/',
),
Uri.parse('https://recent-messages.robotty.de/'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
@ -123,12 +69,15 @@ class OnboardingSetup extends StatelessWidget {
onChanged: (newValue) {
settingsStore.shareCrashLogsAndAnalytics = newValue;
FirebaseCrashlytics.instance
.setCrashlyticsCollectionEnabled(newValue);
FirebaseAnalytics.instance
.setAnalyticsCollectionEnabled(newValue);
FirebasePerformance.instance
.setPerformanceCollectionEnabled(newValue);
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(
newValue,
);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(
newValue,
);
FirebasePerformance.instance.setPerformanceCollectionEnabled(
newValue,
);
},
),
],

View file

@ -3,6 +3,8 @@ import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/settings/account/blocked_users.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
class AccountOptions extends StatelessWidget {
final AuthStore authStore;
@ -12,21 +14,21 @@ class AccountOptions extends StatelessWidget {
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: const Text('Log out'),
content: const Text('Are you sure you want to log out?'),
builder: (context) => FrostyDialog(
title: 'Log out',
message: 'Are you sure you want to log out?',
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
FilledButton(
onPressed: () {
authStore.logout();
Navigator.pop(context);
Navigator.pop(context);
},
child: const Text('Yes'),
child: const Text('Log out'),
),
],
),
@ -35,7 +37,8 @@ class AccountOptions extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
return FrostyScrollbar(
child: ListView(
shrinkWrap: true,
primary: false,
children: [
@ -52,9 +55,7 @@ class AccountOptions extends StatelessWidget {
SettingsTileRoute(
leading: const Icon(Icons.block_rounded),
title: 'Blocked users',
child: BlockedUsers(
authStore: authStore,
),
child: BlockedUsers(authStore: authStore),
),
ListTile(
leading: const Icon(Icons.logout_rounded),
@ -62,6 +63,7 @@ class AccountOptions extends StatelessWidget {
onTap: () => _showLogoutDialog(context),
),
],
),
);
}
}

View file

@ -4,36 +4,36 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/settings_page_layout.dart';
class BlockedUsers extends StatelessWidget {
final AuthStore authStore;
const BlockedUsers({
super.key,
required this.authStore,
});
const BlockedUsers({super.key, required this.authStore});
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user
.refreshBlockedUsers(headers: authStore.headersTwitch);
await authStore.user.refreshBlockedUsers();
},
child: Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return const Center(
child: AlertMessage(
message: 'No blocked users',
child: const Center(
child: AlertMessage(message: 'No blocked users', vertical: true),
),
);
}
return ListView(
children: authStore.user.blockedUsers.map(
(blockedUser) {
return SettingsPageLayout(
hasBottomPadding: false,
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers();
},
children: authStore.user.blockedUsers.map((blockedUser) {
final displayName = getReadableName(
blockedUser.displayName,
blockedUser.userLogin,
@ -42,9 +42,7 @@ class BlockedUsers extends StatelessWidget {
return ListTile(
title: Text(displayName),
trailing: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
style: TextButton.styleFrom(foregroundColor: Colors.red),
onPressed: () => authStore.showBlockDialog(
context,
targetUser: displayName,
@ -53,11 +51,9 @@ class BlockedUsers extends StatelessWidget {
child: const Text('Unblock'),
),
);
},
).toList(),
}).toList(),
);
},
),
);
}
}

View file

@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/account/account_options.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ProfileCard extends StatelessWidget {
final AuthStore authStore;
@ -13,7 +13,7 @@ class ProfileCard extends StatelessWidget {
const ProfileCard({super.key, required this.authStore});
Future<void> _showAccountOptionsModalBottomSheet(BuildContext context) {
return showModalBottomSheet(
return showModalBottomSheetWithProperFocus(
context: context,
builder: (context) {
return AccountOptions(authStore: authStore);
@ -31,7 +31,7 @@ class ProfileCard extends StatelessWidget {
Icons.error_outline_rounded,
color: Theme.of(context).colorScheme.error,
),
title: const Text('Failed to connect'),
title: const Text('Unable to connect to Twitch'),
trailing: FilledButton.tonal(
onPressed: authStore.init,
child: const Text('Reconnect'),
@ -61,46 +61,12 @@ class ProfileCard extends StatelessWidget {
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: FrostyAppBar(
title: const Text('Connect with Twitch'),
actions: [
IconButton(
icon: const Icon(Icons.help_rounded),
onPressed: () => showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text(
'Workaround for the Twitch cookie banner',
),
content: const Text(
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, exit this page and then try again.',
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Close'),
),
],
);
},
),
),
],
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(),
),
);
},
),
MaterialPageRoute(builder: (context) => LoginWebView()),
),
onLongPress: () async {
final clipboardText =
(await Clipboard.getData(Clipboard.kTextPlain))?.text;
final clipboardText = (await Clipboard.getData(
Clipboard.kTextPlain,
))?.text;
if (clipboardText == null) return;

View file

@ -9,8 +9,10 @@ import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/settings_page_layout.dart';
import 'package:url_launcher/url_launcher.dart';
class ChatSettings extends StatefulWidget {
@ -30,25 +32,19 @@ class _ChatSettingsState extends State<ChatSettings> {
final settingsStore = widget.settingsStore;
return Observer(
builder: (context) => ListView(
padding: const EdgeInsets.only(top: 16),
builder: (context) => SettingsPageLayout(
children: [
const SectionHeader(
'Message sizing',
isFirst: true,
),
const SectionHeader('Message sizing', isFirst: true),
ExpansionTile(
title: const Text(
'Preview',
),
title: const Text('Preview'),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: settingsStore.fontSize),
style: DefaultTextStyle.of(
context,
).style.copyWith(fontSize: settingsStore.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -80,20 +76,17 @@ class _ChatSettingsState extends State<ChatSettings> {
),
],
),
textScaler:
TextScaler.linear(settingsStore.messageScale),
textScaler: settingsStore.messageScale.textScaler,
),
SizedBox(height: settingsStore.messageSpacing),
Text(
'Hello! Here\'s a text preview.',
textScaler:
TextScaler.linear(settingsStore.messageScale),
textScaler: settingsStore.messageScale.textScaler,
),
SizedBox(height: settingsStore.messageSpacing),
Text(
'And another for spacing without an emote!',
textScaler:
TextScaler.linear(settingsStore.messageScale),
textScaler: settingsStore.messageScale.textScaler,
),
],
),
@ -146,14 +139,6 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
const SectionHeader('Message appearance'),
SettingsListSwitch(
title: 'Use readable name colors',
subtitle: const Text(
'Adjusts the lightness value of overly bright and dark names.',
),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SettingsListSwitch(
title: 'Show deleted messages',
subtitle: const Text(
@ -178,7 +163,7 @@ class _ChatSettingsState extends State<ChatSettings> {
),
const SectionHeader('Delay and latency'),
SettingsListSwitch(
title: 'Sync message delay with stream latency (experimental)',
title: 'Sync message delay and stream latency',
value: settingsStore.autoSyncChatDelay,
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
),
@ -209,11 +194,6 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
const SectionHeader('Layout'),
SettingsListSwitch(
title: 'Show bottom bar',
value: settingsStore.showBottomBar,
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
),
SettingsListSwitch(
title: 'Move emote menu button left',
subtitle: const Text(
@ -224,10 +204,17 @@ class _ChatSettingsState extends State<ChatSettings> {
settingsStore.emoteMenuButtonOnLeft = newValue,
),
SettingsListSwitch(
title: 'Move notifications to bottom',
value: settingsStore.chatNotificationsOnBottom,
onChanged: (newValue) =>
settingsStore.chatNotificationsOnBottom = newValue,
title: 'Persist chat tabs',
subtitle: const Text(
'Secondary chat tabs are remembered when switching channels.',
),
value: settingsStore.persistChatTabs,
onChanged: (newValue) {
settingsStore.persistChatTabs = newValue;
if (!newValue) {
settingsStore.secondaryTabs = [];
}
},
),
const SectionHeader('Landscape mode'),
SettingsListSwitch(
@ -238,8 +225,9 @@ class _ChatSettingsState extends State<ChatSettings> {
),
SettingsListSwitch(
title: 'Force vertical chat',
subtitle:
const Text('Intended for tablets and other larger displays.'),
subtitle: const Text(
'Intended for tablets and other larger displays.',
),
value: settingsStore.landscapeForceVerticalChat,
onChanged: (newValue) =>
settingsStore.landscapeForceVerticalChat = newValue,
@ -252,17 +240,9 @@ class _ChatSettingsState extends State<ChatSettings> {
landscapeCutoutNames[settingsStore.landscapeCutout.index],
options: landscapeCutoutNames,
onChanged: (newValue) => settingsStore.landscapeCutout =
LandscapeCutoutType
.values[landscapeCutoutNames.indexOf(newValue)],
),
SettingsListSlider(
title: 'Chat width',
trailing: '${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%',
value: settingsStore.chatWidth,
min: 0.2,
max: 0.6,
divisions: 8,
onChanged: (newValue) => settingsStore.chatWidth = newValue,
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
newValue,
)],
),
SettingsListSlider(
title: 'Chat overlay opacity',
@ -275,16 +255,6 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) =>
settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const SectionHeader('Sleep'),
SettingsListSwitch(
title: 'Prevent sleep in chat-only mode',
subtitle: const Text(
'Requires restarting the chat in order to take effect.',
),
value: settingsStore.chatOnlyPreventSleep,
onChanged: (newValue) =>
settingsStore.chatOnlyPreventSleep = newValue,
),
const SectionHeader('Muted keywords'),
SettingsMutedWords(settingsStore: settingsStore),
SettingsListSwitch(
@ -357,9 +327,7 @@ class _ChatSettingsState extends State<ChatSettings> {
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse(
'https://recent-messages.robotty.de/',
),
Uri.parse('https://recent-messages.robotty.de/'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,

View file

@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
// import removed: flutter_colorpicker
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/accent_color_setting.dart';
import 'package:frosty/widgets/external_browser_setting.dart';
// import removed: frosty/widgets/dialog.dart
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/settings_page_layout.dart';
import 'package:frosty/widgets/theme_selection_setting.dart';
class GeneralSettings extends StatelessWidget {
final SettingsStore settingsStore;
@ -15,66 +18,11 @@ class GeneralSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) => ListView(
padding: const EdgeInsets.only(top: 16),
builder: (context) => SettingsPageLayout(
children: [
const SectionHeader(
'Theme',
isFirst: true,
),
SettingsListSelect(
selectedOption: themeNames[settingsStore.themeType.index],
options: themeNames,
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
ListTile(
title: const Text('Accent color'),
trailing: IconButton(
icon: DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 2,
),
),
child: CircleAvatar(
backgroundColor: Color(settingsStore.accentColor),
radius: 16,
),
),
onPressed: () {
showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Accent color',
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: Color(settingsStore.accentColor),
onColorChanged: (newColor) =>
// TODO: Update when new method arrives in stable:
// https://github.com/flutter/flutter/issues/160184#issuecomment-2560184639
// ignore: deprecated_member_use
settingsStore.accentColor = newColor.value,
enableAlpha: false,
pickerAreaBorderRadius:
const BorderRadius.all(Radius.circular(8)),
labelTypes: const [],
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
],
),
);
},
),
),
const SectionHeader('Theme', isFirst: true),
ThemeSelectionSetting(settingsStore: settingsStore),
AccentColorSetting(settingsStore: settingsStore),
const SectionHeader('Stream card'),
SettingsListSwitch(
title: 'Use large stream card',
@ -87,11 +35,7 @@ class GeneralSettings extends StatelessWidget {
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),
const SectionHeader('Links'),
SettingsListSwitch(
title: 'Open links in external browser',
value: settingsStore.launchUrlExternal,
onChanged: (newValue) => settingsStore.launchUrlExternal = newValue,
),
ExternalBrowserSetting(settingsStore: settingsStore),
],
),
);

View file

@ -10,16 +10,14 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/release_notes.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class OtherSettings extends StatefulWidget {
final SettingsStore settingsStore;
const OtherSettings({
super.key,
required this.settingsStore,
});
const OtherSettings({super.key, required this.settingsStore});
@override
State<OtherSettings> createState() => _OtherSettingsState();
@ -29,15 +27,15 @@ class _OtherSettingsState extends State<OtherSettings> {
Future<void> _showConfirmDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: const Text('Reset all settings'),
content: const Text('Are you sure you want to reset all settings?'),
builder: (context) => FrostyDialog(
title: 'Reset all settings',
message: 'Are you sure you want to reset all settings?',
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
FilledButton(
onPressed: () {
HapticFeedback.heavyImpact();
@ -54,7 +52,7 @@ class _OtherSettingsState extends State<OtherSettings> {
),
);
},
child: const Text('Yes'),
child: const Text('Reset'),
),
],
),
@ -89,11 +87,9 @@ class _OtherSettingsState extends State<OtherSettings> {
ListTile(
leading: const Icon(Icons.notes_rounded),
title: const Text('Release notes'),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ReleaseNotes(),
),
),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const ReleaseNotes())),
),
ListTile(
leading: const Icon(Icons.launch_rounded),
@ -141,12 +137,15 @@ class _OtherSettingsState extends State<OtherSettings> {
onChanged: (newValue) {
widget.settingsStore.shareCrashLogsAndAnalytics = newValue;
FirebaseCrashlytics.instance
.setCrashlyticsCollectionEnabled(newValue);
FirebaseAnalytics.instance
.setAnalyticsCollectionEnabled(newValue);
FirebasePerformance.instance
.setPerformanceCollectionEnabled(newValue);
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(
newValue,
);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(
newValue,
);
FirebasePerformance.instance.setPerformanceCollectionEnabled(
newValue,
);
},
),
),

View file

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/screens/settings/account/widgets/profile_card.dart';
import 'package:frosty/screens/settings/chat_settings.dart';
import 'package:frosty/screens/settings/general_settings.dart';
@ -9,7 +10,8 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/video_settings.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
import 'package:simple_icons/simple_icons.dart';
@ -22,8 +24,28 @@ class Settings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: FrostyAppBar(
backgroundColor: theme.scaffoldBackgroundColor,
extendBody: true,
extendBodyBehindAppBar: true,
appBar: AppBar(
centerTitle: false,
elevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: theme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
),
leading: IconButton(
tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
title: const Text('Settings'),
actions: [
if (Platform.isAndroid)
@ -52,15 +74,19 @@ class Settings extends StatelessWidget {
),
],
),
body: SafeArea(
bottom: false,
child: ListView(
padding: const EdgeInsets.only(top: 16),
body: Stack(
children: [
const SectionHeader(
'Profile',
isFirst: true,
// Main scrollable content
FrostyScrollbar(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + kToolbarHeight,
),
child: ListView(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + kToolbarHeight + 8,
),
children: [
const SectionHeader('Account', isFirst: true),
ProfileCard(authStore: context.read<AuthStore>()),
const SectionHeader('Customize'),
SettingsTileRoute(
@ -83,6 +109,23 @@ class Settings extends StatelessWidget {
],
),
),
// Blurred app bar overlay
Positioned(
top: 0,
left: 0,
right: 0,
child: BlurredContainer(
gradientDirection: GradientDirection.up,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: const SizedBox(height: kToolbarHeight),
),
),
],
),
);
}
}

View file

@ -2,10 +2,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:mobx/mobx.dart';
import 'package:webview_flutter/webview_flutter.dart';
@ -29,6 +31,15 @@ abstract class AuthBase with Store {
/// Whether the token is valid or not.
var _tokenIsValid = false;
/// Timer used to retry authentication when offline or on transient failures.
Timer? _reconnectTimer;
/// Retry count for reconnection attempts.
var _reconnectAttempts = 0;
/// Maximum number of reconnection attempts before giving up.
static const _maxReconnectAttempts = 5;
/// The MobX store containing information relevant to the current user.
final UserStore user;
@ -42,8 +53,10 @@ abstract class AuthBase with Store {
/// Authentication headers for Twitch API requests.
@computed
Map<String, String> get headersTwitch =>
{'Authorization': 'Bearer $_token', 'Client-Id': clientId};
Map<String, String> get headersTwitch => {
'Authorization': 'Bearer $_token',
'Client-Id': clientId,
};
/// Error flag that will be non-null and contain an error message if login failed.
@readonly
@ -70,8 +83,9 @@ abstract class AuthBase with Store {
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
if (routeAfter != null) {
navigatorKey.currentState?.pop();
navigatorKey.currentState
?.push(MaterialPageRoute(builder: (context) => routeAfter));
navigatorKey.currentState?.push(
MaterialPageRoute(builder: (context) => routeAfter),
);
} else {
// Pop twice, once to dismiss the WebView and again to dismiss the Login dialog.
navigatorKey.currentState?.pop();
@ -92,8 +106,12 @@ abstract class AuthBase with Store {
NavigationDelegate(
onNavigationRequest: (request) =>
handleNavigation(request: request, routeAfter: routeAfter),
onPageFinished: (_) => webViewController.runJavaScript(
'''
onWebResourceError: (error) {
debugPrint('Auth WebView error: ${error.description}');
},
onPageFinished: (_) async {
try {
await webViewController.runJavaScript('''
{
function modifyElement(element) {
element.style.maxHeight = '20vh';
@ -118,8 +136,11 @@ abstract class AuthBase with Store {
subtree: true
});
}
''',
),
''');
} catch (e) {
debugPrint('Auth WebView JavaScript error: $e');
}
},
),
)
..loadRequest(
@ -132,7 +153,7 @@ abstract class AuthBase with Store {
'redirect_uri': 'https://twitch.tv/login',
'response_type': 'token',
'scope':
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users',
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color',
'force_verify': 'true',
},
),
@ -156,31 +177,24 @@ abstract class AuthBase with Store {
void onPressed() {
if (isBlocked) {
user.unblock(targetId: targetUserId, headers: headersTwitch);
user.unblock(targetId: targetUserId);
} else {
user.block(
targetId: targetUserId,
displayName: targetUser,
headers: headersTwitch,
);
user.block(targetId: targetUserId, displayName: targetUser);
}
Navigator.pop(context);
}
return showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(title),
content: Text(message),
builder: (context) => FrostyDialog(
title: title,
message: message,
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: onPressed,
child: const Text('Yes'),
),
FilledButton(onPressed: onPressed, child: const Text('Yes')),
],
),
);
@ -206,16 +220,26 @@ abstract class AuthBase with Store {
await _storage.write(key: _defaultTokenKey, value: _token);
}
} else {
// Validate the existing token.
// Validate the existing token. If it fails, start reconnect loop.
try {
_tokenIsValid = await twitchApi.validateToken(token: _token!);
} on ApiException catch (e) {
debugPrint('Token validation failed: $e');
_isLoggedIn = false;
_startReconnectLoop();
return;
}
// If the token is invalid, logout.
if (!_tokenIsValid) return await logout();
// Initialize the user store.
await user.init(headers: headersTwitch);
await user.init();
if (user.details != null) _isLoggedIn = true;
if (user.details != null) {
_isLoggedIn = true;
_stopReconnectLoop();
}
}
_error = null;
@ -240,10 +264,13 @@ abstract class AuthBase with Store {
await _storage.write(key: _userTokenKey, value: token);
// Initialize the user with the new token.
await user.init(headers: headersTwitch);
await user.init();
// Set the login status to logged in.
if (user.details != null) _isLoggedIn = true;
if (user.details != null) {
_isLoggedIn = true;
_stopReconnectLoop();
}
} catch (e) {
debugPrint('Login failed due to $e');
}
@ -253,6 +280,7 @@ abstract class AuthBase with Store {
@action
Future<void> logout() async {
try {
_stopReconnectLoop();
// Delete the existing user token.
await _storage.delete(key: _userTokenKey);
_token = null;
@ -277,4 +305,49 @@ abstract class AuthBase with Store {
debugPrint(e.toString());
}
}
void _startReconnectLoop() {
if (_reconnectTimer != null) return;
_reconnectAttempts = 0;
_reconnectTimer = Timer.periodic(const Duration(seconds: 10), (_) async {
try {
_reconnectAttempts++;
if (_reconnectAttempts > _maxReconnectAttempts) {
await logout();
return;
}
final stored = await _storage.read(key: _userTokenKey);
if (stored == null) {
_stopReconnectLoop();
return;
}
final isValid = await twitchApi.validateToken(token: stored);
if (!isValid) {
await logout();
return;
}
// Token valid again — restore session.
_token = stored;
await user.init();
if (user.details != null) {
_isLoggedIn = true;
_error = null;
_stopReconnectLoop();
}
} on ApiException catch (_) {
// Continue trying
} catch (e) {
debugPrint('Reconnect loop error: $e');
}
});
}
void _stopReconnectLoop() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
_reconnectAttempts = 0;
}
}

Some files were not shown because too many files have changed in this diff Show more