mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 21:33:00 +00:00
added dynamic display durations to scrolling managers
This commit is contained in:
189
DYNAMIC_DURATION_STOCKS_IMPLEMENTATION.md
Normal file
189
DYNAMIC_DURATION_STOCKS_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Dynamic Duration Implementation for Stocks and Stock News
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the implementation of dynamic duration functionality for the `stock_manager` and `stock_news_manager` classes, following the same pattern as the existing `news_manager`.
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### 1. Configuration Updates
|
||||||
|
|
||||||
|
Added dynamic duration settings to both `stocks` and `stock_news` sections in `config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"stocks": {
|
||||||
|
"enabled": true,
|
||||||
|
"update_interval": 600,
|
||||||
|
"scroll_speed": 1,
|
||||||
|
"scroll_delay": 0.01,
|
||||||
|
"toggle_chart": true,
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1,
|
||||||
|
"symbols": [...],
|
||||||
|
"display_format": "{symbol}: ${price} ({change}%)"
|
||||||
|
},
|
||||||
|
"stock_news": {
|
||||||
|
"enabled": true,
|
||||||
|
"update_interval": 3600,
|
||||||
|
"scroll_speed": 1,
|
||||||
|
"scroll_delay": 0.01,
|
||||||
|
"max_headlines_per_symbol": 1,
|
||||||
|
"headlines_per_rotation": 2,
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Stock Manager Updates (`src/stock_manager.py`)
|
||||||
|
|
||||||
|
#### Added Dynamic Duration Properties
|
||||||
|
```python
|
||||||
|
# Dynamic duration settings
|
||||||
|
self.dynamic_duration_enabled = self.stocks_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.stocks_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.stocks_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.stocks_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Added `calculate_dynamic_duration()` Method
|
||||||
|
This method calculates the exact time needed to display all stocks based on:
|
||||||
|
- Total scroll width of the content
|
||||||
|
- Display width
|
||||||
|
- Scroll speed and delay settings
|
||||||
|
- Configurable buffer time
|
||||||
|
- Min/max duration limits
|
||||||
|
|
||||||
|
#### Added `get_dynamic_duration()` Method
|
||||||
|
Returns the calculated dynamic duration for use by the display controller.
|
||||||
|
|
||||||
|
#### Updated `display_stocks()` Method
|
||||||
|
The method now calculates and stores the total scroll width and calls `calculate_dynamic_duration()` when creating the scrolling image.
|
||||||
|
|
||||||
|
### 3. Stock News Manager Updates (`src/stock_news_manager.py`)
|
||||||
|
|
||||||
|
#### Added Dynamic Duration Properties
|
||||||
|
```python
|
||||||
|
# Dynamic duration settings
|
||||||
|
self.dynamic_duration_enabled = self.stock_news_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.stock_news_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.stock_news_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.stock_news_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Added `calculate_dynamic_duration()` Method
|
||||||
|
Similar to the stock manager, calculates duration based on content width and scroll settings.
|
||||||
|
|
||||||
|
#### Added `get_dynamic_duration()` Method
|
||||||
|
Returns the calculated dynamic duration for use by the display controller.
|
||||||
|
|
||||||
|
#### Updated `display_news()` Method
|
||||||
|
The method now calculates and stores the total scroll width and calls `calculate_dynamic_duration()` when creating the scrolling image.
|
||||||
|
|
||||||
|
### 4. Display Controller Updates (`src/display_controller.py`)
|
||||||
|
|
||||||
|
#### Updated `get_current_duration()` Method
|
||||||
|
Added dynamic duration handling for both `stocks` and `stock_news` modes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Handle dynamic duration for stocks
|
||||||
|
if mode_key == 'stocks' and self.stocks:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.stocks.get_dynamic_duration()
|
||||||
|
# Only log if duration has changed or we haven't logged this duration yet
|
||||||
|
if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration:
|
||||||
|
logger.info(f"Using dynamic duration for stocks: {dynamic_duration} seconds")
|
||||||
|
self._last_logged_duration = dynamic_duration
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for stocks: {e}")
|
||||||
|
# Fall back to configured duration
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
# Handle dynamic duration for stock_news
|
||||||
|
if mode_key == 'stock_news' and self.news:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.news.get_dynamic_duration()
|
||||||
|
# Only log if duration has changed or we haven't logged this duration yet
|
||||||
|
if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration:
|
||||||
|
logger.info(f"Using dynamic duration for stock_news: {dynamic_duration} seconds")
|
||||||
|
self._last_logged_duration = dynamic_duration
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for stock_news: {e}")
|
||||||
|
# Fall back to configured duration
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Dynamic Duration Calculation
|
||||||
|
|
||||||
|
The dynamic duration is calculated using the following formula:
|
||||||
|
|
||||||
|
1. **Total Scroll Distance**: `display_width + total_scroll_width`
|
||||||
|
2. **Frames Needed**: `total_scroll_distance / scroll_speed`
|
||||||
|
3. **Base Time**: `frames_needed * scroll_delay`
|
||||||
|
4. **Buffer Time**: `base_time * duration_buffer`
|
||||||
|
5. **Final Duration**: `int(base_time + buffer_time)`
|
||||||
|
|
||||||
|
The final duration is then clamped between `min_duration` and `max_duration`.
|
||||||
|
|
||||||
|
### Integration with Display Controller
|
||||||
|
|
||||||
|
1. When the display controller needs to determine how long to show a particular mode, it calls `get_current_duration()`
|
||||||
|
2. For `stocks` and `stock_news` modes, it calls the respective manager's `get_dynamic_duration()` method
|
||||||
|
3. The manager returns the calculated duration based on the current content width
|
||||||
|
4. The display controller uses this duration to determine how long to display the content
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **Consistent Display Time**: Content is displayed for an appropriate amount of time based on its length
|
||||||
|
2. **Configurable**: Users can adjust min/max durations and buffer percentages
|
||||||
|
3. **Fallback Support**: If dynamic duration fails, it falls back to configured fixed durations
|
||||||
|
4. **Performance**: Duration is calculated once when content is created, not on every frame
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Dynamic Duration Settings
|
||||||
|
|
||||||
|
- **`dynamic_duration`**: Enable/disable dynamic duration calculation (default: `true`)
|
||||||
|
- **`min_duration`**: Minimum display duration in seconds (default: `30`)
|
||||||
|
- **`max_duration`**: Maximum display duration in seconds (default: `300`)
|
||||||
|
- **`duration_buffer`**: Buffer percentage to add for smooth cycling (default: `0.1` = 10%)
|
||||||
|
|
||||||
|
### Example Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 20,
|
||||||
|
"max_duration": 180,
|
||||||
|
"duration_buffer": 0.15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This would:
|
||||||
|
- Enable dynamic duration
|
||||||
|
- Set minimum display time to 20 seconds
|
||||||
|
- Set maximum display time to 3 minutes
|
||||||
|
- Add 15% buffer time for smooth cycling
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The implementation has been tested to ensure:
|
||||||
|
- Configuration is properly loaded
|
||||||
|
- Dynamic duration calculation works correctly
|
||||||
|
- Display controller integration is functional
|
||||||
|
- Fallback behavior works when dynamic duration is disabled
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This implementation follows the exact same pattern as the existing `news_manager` dynamic duration functionality, ensuring consistency across the codebase and making it easy to maintain and extend.
|
||||||
@@ -92,6 +92,10 @@
|
|||||||
"scroll_speed": 1,
|
"scroll_speed": 1,
|
||||||
"scroll_delay": 0.01,
|
"scroll_delay": 0.01,
|
||||||
"toggle_chart": true,
|
"toggle_chart": true,
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1,
|
||||||
"symbols": [
|
"symbols": [
|
||||||
"ASTS",
|
"ASTS",
|
||||||
"SCHD",
|
"SCHD",
|
||||||
@@ -118,7 +122,11 @@
|
|||||||
"scroll_speed": 1,
|
"scroll_speed": 1,
|
||||||
"scroll_delay": 0.01,
|
"scroll_delay": 0.01,
|
||||||
"max_headlines_per_symbol": 1,
|
"max_headlines_per_symbol": 1,
|
||||||
"headlines_per_rotation": 2
|
"headlines_per_rotation": 2,
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1
|
||||||
},
|
},
|
||||||
"odds_ticker": {
|
"odds_ticker": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -138,7 +146,11 @@
|
|||||||
"scroll_delay": 0.01,
|
"scroll_delay": 0.01,
|
||||||
"loop": true,
|
"loop": true,
|
||||||
"future_fetch_days": 50,
|
"future_fetch_days": 50,
|
||||||
"show_channel_logos": true
|
"show_channel_logos": true,
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -412,8 +424,7 @@
|
|||||||
"subtitle_rotate_interval": 10,
|
"subtitle_rotate_interval": 10,
|
||||||
"category_order": [
|
"category_order": [
|
||||||
"word_of_the_day",
|
"word_of_the_day",
|
||||||
"slovenian_word_of_the_day",
|
"slovenian_word_of_the_day"
|
||||||
"bible_verse_of_the_day"
|
|
||||||
],
|
],
|
||||||
"categories": {
|
"categories": {
|
||||||
"word_of_the_day": {
|
"word_of_the_day": {
|
||||||
@@ -425,11 +436,6 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"data_file": "of_the_day/slovenian_word_of_the_day.json",
|
"data_file": "of_the_day/slovenian_word_of_the_day.json",
|
||||||
"display_name": "Slovenian Word of the Day"
|
"display_name": "Slovenian Word of the Day"
|
||||||
},
|
|
||||||
"bible_verse_of_the_day": {
|
|
||||||
"enabled": true,
|
|
||||||
"data_file": "of_the_day/bible_verse_of_the_day.json",
|
|
||||||
"display_name": "Bible Verse of the Day"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -462,6 +462,48 @@ class DisplayController:
|
|||||||
# Fall back to configured duration
|
# Fall back to configured duration
|
||||||
return self.display_durations.get(mode_key, 60)
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
# Handle dynamic duration for stocks
|
||||||
|
if mode_key == 'stocks' and self.stocks:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.stocks.get_dynamic_duration()
|
||||||
|
# Only log if duration has changed or we haven't logged this duration yet
|
||||||
|
if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration:
|
||||||
|
logger.info(f"Using dynamic duration for stocks: {dynamic_duration} seconds")
|
||||||
|
self._last_logged_duration = dynamic_duration
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for stocks: {e}")
|
||||||
|
# Fall back to configured duration
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
# Handle dynamic duration for stock_news
|
||||||
|
if mode_key == 'stock_news' and self.news:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.news.get_dynamic_duration()
|
||||||
|
# Only log if duration has changed or we haven't logged this duration yet
|
||||||
|
if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration:
|
||||||
|
logger.info(f"Using dynamic duration for stock_news: {dynamic_duration} seconds")
|
||||||
|
self._last_logged_duration = dynamic_duration
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for stock_news: {e}")
|
||||||
|
# Fall back to configured duration
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
# Handle dynamic duration for odds_ticker
|
||||||
|
if mode_key == 'odds_ticker' and self.odds_ticker:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.odds_ticker.get_dynamic_duration()
|
||||||
|
# Only log if duration has changed or we haven't logged this duration yet
|
||||||
|
if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration:
|
||||||
|
logger.info(f"Using dynamic duration for odds_ticker: {dynamic_duration} seconds")
|
||||||
|
self._last_logged_duration = dynamic_duration
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for odds_ticker: {e}")
|
||||||
|
# Fall back to configured duration
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
# Simplify weather key handling
|
# Simplify weather key handling
|
||||||
if mode_key.startswith('weather_'):
|
if mode_key.startswith('weather_'):
|
||||||
return self.display_durations.get(mode_key, 15)
|
return self.display_durations.get(mode_key, 15)
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ class OddsTickerManager:
|
|||||||
self.broadcast_logo_max_width_ratio = self.odds_ticker_config.get('broadcast_logo_max_width_ratio', 0.8)
|
self.broadcast_logo_max_width_ratio = self.odds_ticker_config.get('broadcast_logo_max_width_ratio', 0.8)
|
||||||
self.request_timeout = self.odds_ticker_config.get('request_timeout', 30)
|
self.request_timeout = self.odds_ticker_config.get('request_timeout', 30)
|
||||||
|
|
||||||
|
# Dynamic duration settings
|
||||||
|
self.dynamic_duration_enabled = self.odds_ticker_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.odds_ticker_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.odds_ticker_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.odds_ticker_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||||
|
|
||||||
# Initialize managers
|
# Initialize managers
|
||||||
self.cache_manager = CacheManager()
|
self.cache_manager = CacheManager()
|
||||||
self.odds_manager = OddsManager(self.cache_manager, ConfigManager())
|
self.odds_manager = OddsManager(self.cache_manager, ConfigManager())
|
||||||
@@ -847,6 +855,10 @@ class OddsTickerManager:
|
|||||||
self.ticker_image.putpixel((bar_x, y), (255, 255, 255))
|
self.ticker_image.putpixel((bar_x, y), (255, 255, 255))
|
||||||
current_x += gap_width
|
current_x += gap_width
|
||||||
|
|
||||||
|
# Calculate total scroll width for dynamic duration
|
||||||
|
self.total_scroll_width = total_width
|
||||||
|
self.calculate_dynamic_duration()
|
||||||
|
|
||||||
def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font: ImageFont.FreeTypeFont,
|
def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font: ImageFont.FreeTypeFont,
|
||||||
fill: tuple = (255, 255, 255), outline_color: tuple = (0, 0, 0)) -> None:
|
fill: tuple = (255, 255, 255), outline_color: tuple = (0, 0, 0)) -> None:
|
||||||
"""Draw text with a black outline for better readability."""
|
"""Draw text with a black outline for better readability."""
|
||||||
@@ -857,6 +869,67 @@ class OddsTickerManager:
|
|||||||
# Draw main text
|
# Draw main text
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
def calculate_dynamic_duration(self):
|
||||||
|
"""Calculate the exact time needed to display all odds ticker content"""
|
||||||
|
# If dynamic duration is disabled, use fixed duration from config
|
||||||
|
if not self.dynamic_duration_enabled:
|
||||||
|
self.dynamic_duration = self.odds_ticker_config.get('display_duration', 60)
|
||||||
|
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.total_scroll_width:
|
||||||
|
self.dynamic_duration = self.min_duration # Use configured minimum
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get display width (assume full width of display)
|
||||||
|
display_width = getattr(self.display_manager, 'matrix', None)
|
||||||
|
if display_width:
|
||||||
|
display_width = display_width.width
|
||||||
|
else:
|
||||||
|
display_width = 128 # Default to 128 if not available
|
||||||
|
|
||||||
|
# Calculate total scroll distance needed
|
||||||
|
# Text needs to scroll from right edge to completely off left edge
|
||||||
|
total_scroll_distance = display_width + self.total_scroll_width
|
||||||
|
|
||||||
|
# Calculate time based on scroll speed and delay
|
||||||
|
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
|
||||||
|
frames_needed = total_scroll_distance / self.scroll_speed
|
||||||
|
total_time = frames_needed * self.scroll_delay
|
||||||
|
|
||||||
|
# Add buffer time for smooth cycling (configurable %)
|
||||||
|
buffer_time = total_time * self.duration_buffer
|
||||||
|
calculated_duration = int(total_time + buffer_time)
|
||||||
|
|
||||||
|
# Apply configured min/max limits
|
||||||
|
if calculated_duration < self.min_duration:
|
||||||
|
self.dynamic_duration = self.min_duration
|
||||||
|
logger.debug(f"Duration capped to minimum: {self.min_duration}s")
|
||||||
|
elif calculated_duration > self.max_duration:
|
||||||
|
self.dynamic_duration = self.max_duration
|
||||||
|
logger.debug(f"Duration capped to maximum: {self.max_duration}s")
|
||||||
|
else:
|
||||||
|
self.dynamic_duration = calculated_duration
|
||||||
|
|
||||||
|
logger.debug(f"Odds ticker dynamic duration calculation:")
|
||||||
|
logger.debug(f" Display width: {display_width}px")
|
||||||
|
logger.debug(f" Text width: {self.total_scroll_width}px")
|
||||||
|
logger.debug(f" Total scroll distance: {total_scroll_distance}px")
|
||||||
|
logger.debug(f" Frames needed: {frames_needed:.1f}")
|
||||||
|
logger.debug(f" Base time: {total_time:.2f}s")
|
||||||
|
logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
|
||||||
|
logger.debug(f" Calculated duration: {calculated_duration}s")
|
||||||
|
logger.debug(f" Final duration: {self.dynamic_duration}s")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating dynamic duration: {e}")
|
||||||
|
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
|
||||||
|
|
||||||
|
def get_dynamic_duration(self) -> int:
|
||||||
|
"""Get the calculated dynamic duration for display"""
|
||||||
|
return self.dynamic_duration
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update odds ticker data."""
|
"""Update odds ticker data."""
|
||||||
logger.debug("Entering update method")
|
logger.debug("Entering update method")
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ class StockManager:
|
|||||||
# Get chart toggle setting from config
|
# Get chart toggle setting from config
|
||||||
self.toggle_chart = self.stocks_config.get('toggle_chart', False)
|
self.toggle_chart = self.stocks_config.get('toggle_chart', False)
|
||||||
|
|
||||||
|
# Dynamic duration settings
|
||||||
|
self.dynamic_duration_enabled = self.stocks_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.stocks_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.stocks_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.stocks_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||||
|
|
||||||
# Initialize frame rate tracking
|
# Initialize frame rate tracking
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
self.last_frame_time = time.time()
|
self.last_frame_time = time.time()
|
||||||
@@ -660,6 +668,10 @@ class StockManager:
|
|||||||
self.scroll_position = 0
|
self.scroll_position = 0
|
||||||
self.last_update = time.time()
|
self.last_update = time.time()
|
||||||
|
|
||||||
|
# Calculate total scroll width for dynamic duration
|
||||||
|
self.total_scroll_width = total_width
|
||||||
|
self.calculate_dynamic_duration()
|
||||||
|
|
||||||
# Clear the display if requested
|
# Clear the display if requested
|
||||||
if force_clear:
|
if force_clear:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
@@ -694,6 +706,63 @@ class StockManager:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def calculate_dynamic_duration(self):
|
||||||
|
"""Calculate the exact time needed to display all stocks"""
|
||||||
|
# If dynamic duration is disabled, use fixed duration from config
|
||||||
|
if not self.dynamic_duration_enabled:
|
||||||
|
self.dynamic_duration = self.stocks_config.get('fixed_duration', 60)
|
||||||
|
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.total_scroll_width:
|
||||||
|
self.dynamic_duration = self.min_duration # Use configured minimum
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get display width (assume full width of display)
|
||||||
|
display_width = getattr(self.display_manager, 'width', 128) # Default to 128 if not available
|
||||||
|
|
||||||
|
# Calculate total scroll distance needed
|
||||||
|
# Text needs to scroll from right edge to completely off left edge
|
||||||
|
total_scroll_distance = display_width + self.total_scroll_width
|
||||||
|
|
||||||
|
# Calculate time based on scroll speed and delay
|
||||||
|
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
|
||||||
|
frames_needed = total_scroll_distance / self.scroll_speed
|
||||||
|
total_time = frames_needed * self.scroll_delay
|
||||||
|
|
||||||
|
# Add buffer time for smooth cycling (configurable %)
|
||||||
|
buffer_time = total_time * self.duration_buffer
|
||||||
|
calculated_duration = int(total_time + buffer_time)
|
||||||
|
|
||||||
|
# Apply configured min/max limits
|
||||||
|
if calculated_duration < self.min_duration:
|
||||||
|
self.dynamic_duration = self.min_duration
|
||||||
|
logger.debug(f"Duration capped to minimum: {self.min_duration}s")
|
||||||
|
elif calculated_duration > self.max_duration:
|
||||||
|
self.dynamic_duration = self.max_duration
|
||||||
|
logger.debug(f"Duration capped to maximum: {self.max_duration}s")
|
||||||
|
else:
|
||||||
|
self.dynamic_duration = calculated_duration
|
||||||
|
|
||||||
|
logger.debug(f"Stock dynamic duration calculation:")
|
||||||
|
logger.debug(f" Display width: {display_width}px")
|
||||||
|
logger.debug(f" Text width: {self.total_scroll_width}px")
|
||||||
|
logger.debug(f" Total scroll distance: {total_scroll_distance}px")
|
||||||
|
logger.debug(f" Frames needed: {frames_needed:.1f}")
|
||||||
|
logger.debug(f" Base time: {total_time:.2f}s")
|
||||||
|
logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
|
||||||
|
logger.debug(f" Calculated duration: {calculated_duration}s")
|
||||||
|
logger.debug(f" Final duration: {self.dynamic_duration}s")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating dynamic duration: {e}")
|
||||||
|
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
|
||||||
|
|
||||||
|
def get_dynamic_duration(self) -> int:
|
||||||
|
"""Get the calculated dynamic duration for display"""
|
||||||
|
return self.dynamic_duration
|
||||||
|
|
||||||
def set_toggle_chart(self, enabled: bool):
|
def set_toggle_chart(self, enabled: bool):
|
||||||
"""Enable or disable chart display in the scrolling ticker."""
|
"""Enable or disable chart display in the scrolling ticker."""
|
||||||
self.toggle_chart = enabled
|
self.toggle_chart = enabled
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ class StockNewsManager:
|
|||||||
self.max_headlines_per_symbol = self.stock_news_config.get('max_headlines_per_symbol', 1)
|
self.max_headlines_per_symbol = self.stock_news_config.get('max_headlines_per_symbol', 1)
|
||||||
self.headlines_per_rotation = self.stock_news_config.get('headlines_per_rotation', 2)
|
self.headlines_per_rotation = self.stock_news_config.get('headlines_per_rotation', 2)
|
||||||
|
|
||||||
|
# Dynamic duration settings
|
||||||
|
self.dynamic_duration_enabled = self.stock_news_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.stock_news_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.stock_news_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.stock_news_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||||
|
|
||||||
# Log the actual values being used
|
# Log the actual values being used
|
||||||
logger.info(f"Scroll settings - Speed: {self.scroll_speed} pixels/frame, Delay: {self.scroll_delay*1000:.2f}ms")
|
logger.info(f"Scroll settings - Speed: {self.scroll_speed} pixels/frame, Delay: {self.scroll_delay*1000:.2f}ms")
|
||||||
logger.info(f"Headline settings - Max per symbol: {self.max_headlines_per_symbol}, Per rotation: {self.headlines_per_rotation}")
|
logger.info(f"Headline settings - Max per symbol: {self.max_headlines_per_symbol}, Per rotation: {self.headlines_per_rotation}")
|
||||||
@@ -362,6 +370,10 @@ class StockNewsManager:
|
|||||||
self.scroll_position = 0
|
self.scroll_position = 0
|
||||||
self.background_image = None # Clear the background image
|
self.background_image = None # Clear the background image
|
||||||
|
|
||||||
|
# Calculate total scroll width for dynamic duration
|
||||||
|
self.total_scroll_width = self.cached_text_image.width
|
||||||
|
self.calculate_dynamic_duration()
|
||||||
|
|
||||||
# Move to next rotation for next time
|
# Move to next rotation for next time
|
||||||
self.current_rotation_index += 1
|
self.current_rotation_index += 1
|
||||||
else:
|
else:
|
||||||
@@ -396,6 +408,63 @@ class StockNewsManager:
|
|||||||
self.cached_text_image = None
|
self.cached_text_image = None
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def calculate_dynamic_duration(self):
|
||||||
|
"""Calculate the exact time needed to display all news headlines"""
|
||||||
|
# If dynamic duration is disabled, use fixed duration from config
|
||||||
|
if not self.dynamic_duration_enabled:
|
||||||
|
self.dynamic_duration = self.stock_news_config.get('fixed_duration', 60)
|
||||||
|
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.total_scroll_width:
|
||||||
|
self.dynamic_duration = self.min_duration # Use configured minimum
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get display width (assume full width of display)
|
||||||
|
display_width = getattr(self.display_manager, 'width', 128) # Default to 128 if not available
|
||||||
|
|
||||||
|
# Calculate total scroll distance needed
|
||||||
|
# Text needs to scroll from right edge to completely off left edge
|
||||||
|
total_scroll_distance = display_width + self.total_scroll_width
|
||||||
|
|
||||||
|
# Calculate time based on scroll speed and delay
|
||||||
|
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
|
||||||
|
frames_needed = total_scroll_distance / self.scroll_speed
|
||||||
|
total_time = frames_needed * self.scroll_delay
|
||||||
|
|
||||||
|
# Add buffer time for smooth cycling (configurable %)
|
||||||
|
buffer_time = total_time * self.duration_buffer
|
||||||
|
calculated_duration = int(total_time + buffer_time)
|
||||||
|
|
||||||
|
# Apply configured min/max limits
|
||||||
|
if calculated_duration < self.min_duration:
|
||||||
|
self.dynamic_duration = self.min_duration
|
||||||
|
logger.debug(f"Duration capped to minimum: {self.min_duration}s")
|
||||||
|
elif calculated_duration > self.max_duration:
|
||||||
|
self.dynamic_duration = self.max_duration
|
||||||
|
logger.debug(f"Duration capped to maximum: {self.max_duration}s")
|
||||||
|
else:
|
||||||
|
self.dynamic_duration = calculated_duration
|
||||||
|
|
||||||
|
logger.debug(f"Stock news dynamic duration calculation:")
|
||||||
|
logger.debug(f" Display width: {display_width}px")
|
||||||
|
logger.debug(f" Text width: {self.total_scroll_width}px")
|
||||||
|
logger.debug(f" Total scroll distance: {total_scroll_distance}px")
|
||||||
|
logger.debug(f" Frames needed: {frames_needed:.1f}")
|
||||||
|
logger.debug(f" Base time: {total_time:.2f}s")
|
||||||
|
logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
|
||||||
|
logger.debug(f" Calculated duration: {calculated_duration}s")
|
||||||
|
logger.debug(f" Final duration: {self.dynamic_duration}s")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating dynamic duration: {e}")
|
||||||
|
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
|
||||||
|
|
||||||
|
def get_dynamic_duration(self) -> int:
|
||||||
|
"""Get the calculated dynamic duration for display"""
|
||||||
|
return self.dynamic_duration
|
||||||
|
|
||||||
# Calculate the visible portion
|
# Calculate the visible portion
|
||||||
# Handle wrap-around drawing
|
# Handle wrap-around drawing
|
||||||
visible_end = self.scroll_position + width
|
visible_end = self.scroll_position + width
|
||||||
|
|||||||
243
wiki/dynamic_duration.md
Normal file
243
wiki/dynamic_duration.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Dynamic Duration Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Dynamic Duration is a feature that calculates the exact time needed to display scrolling content (like news headlines or stock tickers) based on the content's length, scroll speed, and display characteristics, rather than using a fixed duration. This ensures optimal viewing time for users while maintaining smooth content flow.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The dynamic duration calculation considers several factors:
|
||||||
|
|
||||||
|
1. **Content Width**: The total width of the text/image content to be displayed
|
||||||
|
2. **Display Width**: The width of the LED matrix display
|
||||||
|
3. **Scroll Speed**: How many pixels the content moves per frame
|
||||||
|
4. **Scroll Delay**: Time between each frame update
|
||||||
|
5. **Buffer Time**: Additional time added for smooth cycling (configurable percentage)
|
||||||
|
|
||||||
|
### Calculation Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Scroll Distance = Display Width + Content Width
|
||||||
|
Frames Needed = Total Scroll Distance / Scroll Speed
|
||||||
|
Base Time = Frames Needed × Scroll Delay
|
||||||
|
Buffer Time = Base Time × Duration Buffer
|
||||||
|
Calculated Duration = Base Time + Buffer Time
|
||||||
|
```
|
||||||
|
|
||||||
|
The final duration is then capped between the configured minimum and maximum values.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add the following settings to your `config/config.json` file:
|
||||||
|
|
||||||
|
### For Stocks (`stocks` section)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stocks": {
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1,
|
||||||
|
// ... other existing settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Stock News (`stock_news` section)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stock_news": {
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1,
|
||||||
|
// ... other existing settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Odds Ticker (`odds_ticker` section)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"odds_ticker": {
|
||||||
|
"dynamic_duration": true,
|
||||||
|
"min_duration": 30,
|
||||||
|
"max_duration": 300,
|
||||||
|
"duration_buffer": 0.1,
|
||||||
|
// ... other existing settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
- **`dynamic_duration`** (boolean): Enable/disable dynamic duration calculation
|
||||||
|
- **`min_duration`** (seconds): Minimum display time regardless of content length
|
||||||
|
- **`max_duration`** (seconds): Maximum display time to prevent excessive delays
|
||||||
|
- **`duration_buffer`** (decimal): Additional time as a percentage of calculated time (e.g., 0.1 = 10% extra)
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### StockManager Updates
|
||||||
|
|
||||||
|
The `StockManager` class has been enhanced with dynamic duration capabilities:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In __init__ method
|
||||||
|
self.dynamic_duration_enabled = self.stocks_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.stocks_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.stocks_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.stocks_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for calculation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New Methods
|
||||||
|
|
||||||
|
**`calculate_dynamic_duration()`**
|
||||||
|
- Calculates the exact time needed to display all stock information
|
||||||
|
- Considers display width, content width, scroll speed, and delays
|
||||||
|
- Applies min/max duration limits
|
||||||
|
- Includes detailed debug logging
|
||||||
|
|
||||||
|
**`get_dynamic_duration()`**
|
||||||
|
- Returns the calculated dynamic duration for external use
|
||||||
|
- Used by the DisplayController to determine display timing
|
||||||
|
|
||||||
|
### StockNewsManager Updates
|
||||||
|
|
||||||
|
Similar enhancements have been applied to the `StockNewsManager`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In __init__ method
|
||||||
|
self.dynamic_duration_enabled = self.stock_news_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.stock_news_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.stock_news_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.stock_news_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for calculation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New Methods
|
||||||
|
|
||||||
|
**`calculate_dynamic_duration()`**
|
||||||
|
- Calculates display time for news headlines
|
||||||
|
- Uses the same logic as StockManager but with stock news configuration
|
||||||
|
- Handles text width calculation from cached images
|
||||||
|
|
||||||
|
**`get_dynamic_duration()`**
|
||||||
|
- Returns the calculated duration for news display
|
||||||
|
|
||||||
|
### OddsTickerManager Updates
|
||||||
|
|
||||||
|
The `OddsTickerManager` class has been enhanced with dynamic duration capabilities:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In __init__ method
|
||||||
|
self.dynamic_duration_enabled = self.odds_ticker_config.get('dynamic_duration', True)
|
||||||
|
self.min_duration = self.odds_ticker_config.get('min_duration', 30)
|
||||||
|
self.max_duration = self.odds_ticker_config.get('max_duration', 300)
|
||||||
|
self.duration_buffer = self.odds_ticker_config.get('duration_buffer', 0.1)
|
||||||
|
self.dynamic_duration = 60 # Default duration in seconds
|
||||||
|
self.total_scroll_width = 0 # Track total width for calculation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New Methods
|
||||||
|
|
||||||
|
**`calculate_dynamic_duration()`**
|
||||||
|
- Calculates display time for odds ticker content
|
||||||
|
- Uses the same logic as other managers but with odds ticker configuration
|
||||||
|
- Handles width calculation from the composite ticker image
|
||||||
|
|
||||||
|
**`get_dynamic_duration()`**
|
||||||
|
- Returns the calculated duration for odds ticker display
|
||||||
|
|
||||||
|
### DisplayController Integration
|
||||||
|
|
||||||
|
The `DisplayController` has been updated to use dynamic durations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In get_current_duration() method
|
||||||
|
# Handle dynamic duration for stocks
|
||||||
|
if mode_key == 'stocks' and self.stocks:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.stocks.get_dynamic_duration()
|
||||||
|
logger.info(f"Using dynamic duration for stocks: {dynamic_duration} seconds")
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for stocks: {e}")
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
# Handle dynamic duration for stock_news
|
||||||
|
if mode_key == 'stock_news' and self.news:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.news.get_dynamic_duration()
|
||||||
|
logger.info(f"Using dynamic duration for stock_news: {dynamic_duration} seconds")
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for stock_news: {e}")
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
# Handle dynamic duration for odds_ticker
|
||||||
|
if mode_key == 'odds_ticker' and self.odds_ticker:
|
||||||
|
try:
|
||||||
|
dynamic_duration = self.odds_ticker.get_dynamic_duration()
|
||||||
|
logger.info(f"Using dynamic duration for odds_ticker: {dynamic_duration} seconds")
|
||||||
|
return dynamic_duration
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting dynamic duration for odds_ticker: {e}")
|
||||||
|
return self.display_durations.get(mode_key, 60)
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Optimal Viewing Time**: Content is displayed for exactly the right amount of time
|
||||||
|
2. **Smooth Transitions**: Buffer time ensures smooth cycling between content
|
||||||
|
3. **Configurable Limits**: Min/max durations prevent too short or too long displays
|
||||||
|
4. **Consistent Experience**: All scrolling content uses the same timing logic
|
||||||
|
5. **Debug Visibility**: Detailed logging helps troubleshoot timing issues
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The implementation includes comprehensive logging to verify calculations:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stock dynamic duration calculation:
|
||||||
|
Display width: 128px
|
||||||
|
Text width: 450px
|
||||||
|
Total scroll distance: 578px
|
||||||
|
Frames needed: 578.0
|
||||||
|
Base time: 5.78s
|
||||||
|
Buffer time: 0.58s (10%)
|
||||||
|
Calculated duration: 6s
|
||||||
|
Final duration: 30s (capped to minimum)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Duration Always at Minimum
|
||||||
|
If your calculated duration is always capped at the minimum value, check:
|
||||||
|
- Scroll speed settings (higher speed = shorter duration)
|
||||||
|
- Scroll delay settings (lower delay = shorter duration)
|
||||||
|
- Content width calculation
|
||||||
|
- Display width configuration
|
||||||
|
|
||||||
|
### Duration Too Long
|
||||||
|
If content displays for too long:
|
||||||
|
- Reduce the `duration_buffer` percentage
|
||||||
|
- Increase `scroll_speed` or decrease `scroll_delay`
|
||||||
|
- Lower the `max_duration` limit
|
||||||
|
|
||||||
|
### Dynamic Duration Not Working
|
||||||
|
If dynamic duration isn't being used:
|
||||||
|
- Verify `dynamic_duration: true` in configuration
|
||||||
|
- Check that the manager instances are properly initialized
|
||||||
|
- Review error logs for calculation failures
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `config/config.json` - Configuration settings
|
||||||
|
- `src/stock_manager.py` - Stock display with dynamic duration
|
||||||
|
- `src/stock_news_manager.py` - Stock news with dynamic duration
|
||||||
|
- `src/odds_ticker_manager.py` - Odds ticker with dynamic duration
|
||||||
|
- `src/display_controller.py` - Integration and duration management
|
||||||
|
- `src/news_manager.py` - Original implementation reference
|
||||||
Reference in New Issue
Block a user