From e24771d55208399b956f1cfcc2d66a49c0ecb9e1 Mon Sep 17 00:00:00 2001 From: projectdx Date: Sun, 11 Jan 2026 20:22:10 +0900 Subject: [PATCH] m3u: Add disk cache persistence and immediate cached-return + background refresh --- lib/providers/m3u_provider.dart | 28 +++++++++ lib/services/m3u_service.dart | 108 +++++++++++++++++++++++++++----- 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/lib/providers/m3u_provider.dart b/lib/providers/m3u_provider.dart index 00479d3..7d07e9c 100644 --- a/lib/providers/m3u_provider.dart +++ b/lib/providers/m3u_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/foundation.dart' show debugPrint; import '../models/stream_entry.dart'; import '../services/m3u_service.dart'; @@ -18,6 +19,22 @@ class ChannelListNotifier extends StateNotifier>> { ChannelListNotifier(this._svc) : super(const AsyncValue.data([])); Future 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(); try { final entries = await _svc.fetch(url, forceRefresh: force); @@ -27,5 +44,16 @@ class ChannelListNotifier extends StateNotifier>> { } } + Future _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 refresh(String url) async => fetch(url, force: true); } diff --git a/lib/services/m3u_service.dart b/lib/services/m3u_service.dart index a0ba568..a62af9e 100644 --- a/lib/services/m3u_service.dart +++ b/lib/services/m3u_service.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:convert'; 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'; class M3uParseException implements Exception { @@ -21,6 +24,70 @@ class M3UService { M3UService({http.Client? client, this.cacheTTL = const Duration(minutes: 5)}) : _client = client ?? http.Client(); + // ----------------------------- Disk cache helpers ----------------------------- + // Stores/read simple JSON cache in the app support directory per M3U URL. + Future _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 _writeCache(String url, List 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?> 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; + 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 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; + 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]. /// Uses a short in-memory cache to avoid repeated network calls. Future> fetch(String url, @@ -33,20 +100,33 @@ class M3UService { return _cachedEntries!; } - final resp = await _client.get(Uri.parse(url)); - if (resp.statusCode != 200) { - throw Exception('Failed to fetch M3U: HTTP ${resp.statusCode}'); + try { + final resp = await _client.get(Uri.parse(url)); + 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}'); + } + + final content = resp.body; + final entries = parse(content); + + // Update memory cache + _cachedUrl = url; + _cachedAt = DateTime.now(); + _cachedEntries = entries; + + // Persist to disk cache (best-effort) + _writeCache(url, entries); // fire-and-forget + + 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; } - - final content = resp.body; - final entries = parse(content); - - // Update cache - _cachedUrl = url; - _cachedAt = DateTime.now(); - _cachedEntries = entries; - - return entries; } /// Parse raw M3U content into [StreamEntry] list.