mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-22 22:48:04 +02:00
Merge pull request #58 from R0m1k3/antigravity
feat: add IPTV data models and application color palette
This commit is contained in:
commit
b1cb42c229
4 files changed
+76
-25
No files matched your search
@@ -159,15 +159,25 @@ class EpgEntry {
|
|||||||
return EpgEntry(
|
return EpgEntry(
|
||||||
id: json['id']?.toString() ?? '',
|
id: json['id']?.toString() ?? '',
|
||||||
epgId: json['epg_id']?.toString() ?? '',
|
epgId: json['epg_id']?.toString() ?? '',
|
||||||
title: json['title']?.toString() ?? '',
|
title: _decodeBase64(json['title']?.toString()),
|
||||||
lang: json['lang']?.toString() ?? '',
|
lang: json['lang']?.toString() ?? '',
|
||||||
start: json['start']?.toString() ?? '',
|
start: json['start']?.toString() ?? '',
|
||||||
end: json['end']?.toString() ?? '',
|
end: json['end']?.toString() ?? '',
|
||||||
description: json['description']?.toString() ?? '',
|
description: _decodeBase64(json['description']?.toString()),
|
||||||
channelId: json['channel_id']?.toString() ?? '',
|
channelId: json['channel_id']?.toString() ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _decodeBase64(String? text) {
|
||||||
|
if (text == null || text.isEmpty) return '';
|
||||||
|
try {
|
||||||
|
final bytes = base64Decode(text);
|
||||||
|
return utf8.decode(bytes, allowMalformed: true);
|
||||||
|
} catch (_) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate progress percentage for current program
|
/// Calculate progress percentage for current program
|
||||||
double getProgress() {
|
double getProgress() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ class AppColors {
|
|||||||
static const Color background = Color(0xFF000000);
|
static const Color background = Color(0xFF000000);
|
||||||
|
|
||||||
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
|
/// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey)
|
||||||
static const Color surface = Color(0xFF1C1C1E);
|
static const Color surface = Color(0xFF2C2C2E);
|
||||||
|
|
||||||
/// Slightly lighter grey for secondary surfaces or hover states
|
/// Slightly lighter grey for secondary surfaces or hover states
|
||||||
static const Color surfaceVariant = Color(0xFF2C2C2E);
|
static const Color surfaceVariant = Color(0xFF3A3A3C);
|
||||||
|
|
||||||
/// Focused element background (often white in tvOS for text, or bright accent)
|
/// Focused element background (often white in tvOS for text, or bright accent)
|
||||||
static const Color focusColor = Color(0xFFFFFFFF);
|
static const Color focusColor = Color(0xFFFFFFFF);
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../iptv/providers/xtream_provider.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
|
||||||
import '../providers/favorites_provider.dart';
|
|
||||||
|
|
||||||
/// Apple TV Style Channel Card
|
|
||||||
///
|
|
||||||
/// "Pop-out" effect on focus:
|
|
||||||
/// - Scales up (1.0 -> 1.1)
|
|
||||||
/// - Throws a large soft shadow
|
|
||||||
/// - Adds a thick white border (Focus Ring)
|
|
||||||
/// - Title is typically BELOW the card in tvOS, but we'll overlay it subtly or keep below.
|
|
||||||
class ChannelCard extends ConsumerStatefulWidget {
|
class ChannelCard extends ConsumerStatefulWidget {
|
||||||
final String streamId;
|
final String streamId;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -21,11 +12,13 @@ class ChannelCard extends ConsumerStatefulWidget {
|
|||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
|
final PlaylistConfig playlist;
|
||||||
|
|
||||||
const ChannelCard({
|
const ChannelCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.streamId,
|
required this.streamId,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.playlist,
|
||||||
this.iconUrl,
|
this.iconUrl,
|
||||||
this.currentProgram,
|
this.currentProgram,
|
||||||
this.isLive = true,
|
this.isLive = true,
|
||||||
@@ -42,7 +35,11 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
bool _isHovered = false;
|
bool _isHovered = false;
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
// late Animation<double> _pulseAnimation; // Unused for now, focus is static
|
|
||||||
|
// EPG State
|
||||||
|
String? _epgNow;
|
||||||
|
bool _epgLoaded = false;
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -68,8 +65,54 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
|
|||||||
// In valid tvOS, the layout reserves space, so we scale the content but not the layout bounds to avoid reflow.
|
// In valid tvOS, the layout reserves space, so we scale the content but not the layout bounds to avoid reflow.
|
||||||
// However, simplest way here is just Transform.scale.
|
// However, simplest way here is just Transform.scale.
|
||||||
|
|
||||||
|
Future<void> _fetchEpg() async {
|
||||||
|
if (_epgLoaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Don't modify state if unmounted
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||||
|
// We use the short EPG endpoint for quick access
|
||||||
|
final entries = await service.getShortEpg(widget.streamId);
|
||||||
|
|
||||||
|
if (mounted && entries.isNotEmpty) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
// Find current program
|
||||||
|
final current = entries.firstWhere(
|
||||||
|
(e) {
|
||||||
|
try {
|
||||||
|
final start = DateTime.parse(e.start);
|
||||||
|
final end = DateTime.parse(e.end);
|
||||||
|
return now.isAfter(start) && now.isBefore(end);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orElse: () => entries.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_epgNow = current.title;
|
||||||
|
_epgLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fail silently
|
||||||
|
if (mounted) setState(() => _epgLoaded = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final favorites = ref.watch(favoritesProvider);
|
||||||
|
final isFavorite = favorites.contains(widget.streamId);
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _isHovered = true),
|
onEnter: (_) {
|
||||||
|
setState(() => _isHovered = true);
|
||||||
|
if (!_epgLoaded) _fetchEpg();
|
||||||
|
},
|
||||||
onExit: (_) => setState(() => _isHovered = false),
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -120,15 +163,13 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
|
|||||||
// 1. Background Image / Logo - EDGE TO EDGE
|
// 1. Background Image / Logo - EDGE TO EDGE
|
||||||
_buildChannelImage(),
|
_buildChannelImage(),
|
||||||
|
|
||||||
// 2. Subtle Gradient (Always visible to make white logos pop on white background? No, black bg handling)
|
// 2. Gradient Overlay (Subtle)
|
||||||
// Only show gradient if we have text to show ON the card, but for tvOS text is often below.
|
|
||||||
// Let's keep text overlay for now as requested "Minimalist" often implies overlays.
|
|
||||||
if (_isHovered)
|
if (_isHovered)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [Colors.transparent, Colors.black.withOpacity(0.8)],
|
colors: [Colors.transparent, Colors.black.withOpacity(0.4)],
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
),
|
),
|
||||||
@@ -174,8 +215,6 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Text separates from card (Classic tvOS look)
|
// Text separates from card (Classic tvOS look)
|
||||||
// Only show text prominently if hovered? Or always?
|
|
||||||
// "Minimalist" -> Always show but keep it subtle. White when focused.
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: widget.width ?? 180,
|
width: widget.width ?? 180,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -193,10 +232,10 @@ class _ChannelCardState extends ConsumerState<ChannelCard>
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.currentProgram != null && _isHovered) ...[
|
if ((widget.currentProgram != null || _epgNow != null) && _isHovered) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
widget.currentProgram!,
|
_epgNow ?? widget.currentProgram ?? '',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
name: channel.name,
|
name: channel.name,
|
||||||
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||||
currentProgram: null,
|
currentProgram: null,
|
||||||
|
playlist: widget.playlist,
|
||||||
onTap: () => _playChannel(channel),
|
onTap: () => _playChannel(channel),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -354,6 +355,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
iconUrl: _getProxiedIconUrl(channel.streamIcon),
|
||||||
height: 70,
|
height: 70,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
playlist: widget.playlist,
|
||||||
onTap: () => _playChannel(channel),
|
onTap: () => _playChannel(channel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user