m3u: Add disk cache persistence and immediate cached-return + background refresh
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import '../models/stream_entry.dart';
|
import '../models/stream_entry.dart';
|
||||||
import '../services/m3u_service.dart';
|
import '../services/m3u_service.dart';
|
||||||
|
|
||||||
@@ -18,6 +19,22 @@ class ChannelListNotifier extends StateNotifier<AsyncValue<List<StreamEntry>>> {
|
|||||||
ChannelListNotifier(this._svc) : super(const AsyncValue.data([]));
|
ChannelListNotifier(this._svc) : super(const AsyncValue.data([]));
|
||||||
|
|
||||||
Future<void> fetch(String url, {bool force = false}) async {
|
Future<void> fetch(String url, {bool force = false}) async {
|
||||||
|
// If not forcing, try to load a persistent cache first and return it immediately
|
||||||
|
if (!force) {
|
||||||
|
try {
|
||||||
|
final cached = await _svc.loadFromCache(url);
|
||||||
|
if (cached != null) {
|
||||||
|
state = AsyncValue.data(cached);
|
||||||
|
// Refresh in background to get fresh results
|
||||||
|
_refreshInBackground(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore cache read errors and fall back to network fetch
|
||||||
|
debugPrint('Cache read failed during fetch: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
try {
|
try {
|
||||||
final entries = await _svc.fetch(url, forceRefresh: force);
|
final entries = await _svc.fetch(url, forceRefresh: force);
|
||||||
@@ -27,5 +44,16 @@ class ChannelListNotifier extends StateNotifier<AsyncValue<List<StreamEntry>>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshInBackground(String url) async {
|
||||||
|
try {
|
||||||
|
final entries = await _svc.fetch(url, forceRefresh: true);
|
||||||
|
// Only update state if still not error or older content
|
||||||
|
state = AsyncValue.data(entries);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Background refresh failed: $e');
|
||||||
|
// keep cached data; don't overwrite with error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> refresh(String url) async => fetch(url, force: true);
|
Future<void> refresh(String url) async => fetch(url, force: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:meta/meta.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint, unawaited;
|
||||||
import '../models/stream_entry.dart';
|
import '../models/stream_entry.dart';
|
||||||
|
|
||||||
class M3uParseException implements Exception {
|
class M3uParseException implements Exception {
|
||||||
@@ -21,6 +24,70 @@ class M3UService {
|
|||||||
M3UService({http.Client? client, this.cacheTTL = const Duration(minutes: 5)})
|
M3UService({http.Client? client, this.cacheTTL = const Duration(minutes: 5)})
|
||||||
: _client = client ?? http.Client();
|
: _client = client ?? http.Client();
|
||||||
|
|
||||||
|
// ----------------------------- Disk cache helpers -----------------------------
|
||||||
|
// Stores/read simple JSON cache in the app support directory per M3U URL.
|
||||||
|
Future<File> _cacheFileForUrl(String url) async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final safe = base64Url.encode(utf8.encode(url)).replaceAll('=', '');
|
||||||
|
// Truncate filename to avoid filesystem issues
|
||||||
|
final short = safe.length > 120 ? safe.substring(0, 120) : safe;
|
||||||
|
return File('${dir.path}/m3u_cache_$short.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeCache(String url, List<StreamEntry> entries) async {
|
||||||
|
try {
|
||||||
|
final f = await _cacheFileForUrl(url);
|
||||||
|
final jsonBody = jsonEncode({
|
||||||
|
'url': url,
|
||||||
|
'cachedAt': DateTime.now().toIso8601String(),
|
||||||
|
'entries': entries
|
||||||
|
.map((e) => {'title': e.title, 'url': e.url, 'logo': e.logo})
|
||||||
|
.toList()
|
||||||
|
});
|
||||||
|
await f.writeAsString(jsonBody);
|
||||||
|
} catch (e) {
|
||||||
|
// Cache write failures are non-fatal; log and continue
|
||||||
|
debugPrint('M3U cache write failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StreamEntry>?> loadFromCache(String url) async {
|
||||||
|
try {
|
||||||
|
final f = await _cacheFileForUrl(url);
|
||||||
|
if (!await f.exists()) return null;
|
||||||
|
final s = await f.readAsString();
|
||||||
|
final m = jsonDecode(s) as Map<String, dynamic>;
|
||||||
|
if (m['entries'] is List) {
|
||||||
|
final list = (m['entries'] as List)
|
||||||
|
.map((e) => StreamEntry(
|
||||||
|
title: e['title'] ?? '',
|
||||||
|
url: e['url'] ?? '',
|
||||||
|
logo: e['logo'],
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('M3U cache read failed: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> lastCacheTime(String url) async {
|
||||||
|
try {
|
||||||
|
final f = await _cacheFileForUrl(url);
|
||||||
|
if (!await f.exists()) return null;
|
||||||
|
final s = await f.readAsString();
|
||||||
|
final m = jsonDecode(s) as Map<String, dynamic>;
|
||||||
|
if (m['cachedAt'] is String) return DateTime.parse(m['cachedAt']);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('M3U cache timestamp read failed: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------- End disk cache helpers -----------------------------
|
||||||
|
|
||||||
/// Fetches an M3U (over HTTP) and returns parsed list of [StreamEntry].
|
/// Fetches an M3U (over HTTP) and returns parsed list of [StreamEntry].
|
||||||
/// Uses a short in-memory cache to avoid repeated network calls.
|
/// Uses a short in-memory cache to avoid repeated network calls.
|
||||||
Future<List<StreamEntry>> fetch(String url,
|
Future<List<StreamEntry>> fetch(String url,
|
||||||
@@ -33,20 +100,33 @@ class M3UService {
|
|||||||
return _cachedEntries!;
|
return _cachedEntries!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
final resp = await _client.get(Uri.parse(url));
|
final resp = await _client.get(Uri.parse(url));
|
||||||
if (resp.statusCode != 200) {
|
if (resp.statusCode != 200) {
|
||||||
|
// Try to return a local cache if available before failing
|
||||||
|
final cached = await loadFromCache(url);
|
||||||
|
if (cached != null) return cached;
|
||||||
throw Exception('Failed to fetch M3U: HTTP ${resp.statusCode}');
|
throw Exception('Failed to fetch M3U: HTTP ${resp.statusCode}');
|
||||||
}
|
}
|
||||||
|
|
||||||
final content = resp.body;
|
final content = resp.body;
|
||||||
final entries = parse(content);
|
final entries = parse(content);
|
||||||
|
|
||||||
// Update cache
|
// Update memory cache
|
||||||
_cachedUrl = url;
|
_cachedUrl = url;
|
||||||
_cachedAt = DateTime.now();
|
_cachedAt = DateTime.now();
|
||||||
_cachedEntries = entries;
|
_cachedEntries = entries;
|
||||||
|
|
||||||
|
// Persist to disk cache (best-effort)
|
||||||
|
_writeCache(url, entries); // fire-and-forget
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
|
} catch (e) {
|
||||||
|
// Network or other error — fallback to disk cache if possible
|
||||||
|
final cached = await loadFromCache(url);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse raw M3U content into [StreamEntry] list.
|
/// Parse raw M3U content into [StreamEntry] list.
|
||||||
|
|||||||
Reference in New Issue
Block a user