diff --git a/lib/models/stream_entry.dart b/lib/models/stream_entry.dart index 5193c68..b4a95c1 100644 --- a/lib/models/stream_entry.dart +++ b/lib/models/stream_entry.dart @@ -1,11 +1,18 @@ class StreamEntry { final String title; final String url; + final String? logo; // optional TVG logo URL - StreamEntry({required this.title, required this.url}); + StreamEntry({required this.title, required this.url, this.logo}); + + StreamEntry copyWith({String? title, String? url, String? logo}) => + StreamEntry( + title: title ?? this.title, + url: url ?? this.url, + logo: logo ?? this.logo); @override - String toString() => 'StreamEntry(title: $title, url: $url)'; + String toString() => 'StreamEntry(title: $title, url: $url, logo: $logo)'; @override bool operator ==(Object other) => @@ -13,8 +20,9 @@ class StreamEntry { other is StreamEntry && runtimeType == other.runtimeType && title == other.title && - url == other.url; + url == other.url && + logo == other.logo; @override - int get hashCode => title.hashCode ^ url.hashCode; + int get hashCode => title.hashCode ^ url.hashCode ^ (logo?.hashCode ?? 0); } diff --git a/lib/services/m3u_service.dart b/lib/services/m3u_service.dart index 891b70a..a0ba568 100644 --- a/lib/services/m3u_service.dart +++ b/lib/services/m3u_service.dart @@ -60,16 +60,25 @@ class M3UService { final line = raw.trim(); if (line.isEmpty) continue; if (line.startsWith('#EXTINF')) { - // Format: #EXTINF:-1 tvg-id="" tvg-name="Channel" tvg-logo="" group-title="...") ,Display title + // Format: #EXTINF:-1 tvg-id="" tvg-name="Channel" tvg-logo="https://..." group-title="...",Display title final parts = line.split(','); + + // Try to extract attributes like tvg-logo="..." + final logoMatch = RegExp(r'tvg-logo\s*=\s*"([^"]+)"').firstMatch(line); + final logo = logoMatch?.group(1); + if (parts.length >= 2) { pendingTitle = parts.sublist(1).join(',').trim(); } else { - // Fallback: try to extract text after the last space final idx = line.indexOf(','); if (idx >= 0 && idx < line.length - 1) pendingTitle = line.substring(idx + 1).trim(); } + + // Attach logo information to the pending title token using a simple marker so the + // subsequent URL line can pick it up. + if (logo != null && pendingTitle != null) + pendingTitle = '$pendingTitle||logo:$logo'; continue; } @@ -77,8 +86,14 @@ class M3UService { // At this point 'line' is a URL final url = line; - final title = pendingTitle ?? url; - entries.add(StreamEntry(title: title, url: url)); + String title = pendingTitle ?? url; + String? logo; + if (title.contains('||logo:')) { + final parts = title.split('||logo:'); + title = parts[0]; + logo = parts[1]; + } + entries.add(StreamEntry(title: title, url: url, logo: logo)); pendingTitle = null; } diff --git a/lib/widgets/channel_list.dart b/lib/widgets/channel_list.dart index c820971..d6ed876 100644 --- a/lib/widgets/channel_list.dart +++ b/lib/widgets/channel_list.dart @@ -3,32 +3,58 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/stream_entry.dart'; import '../providers/m3u_provider.dart'; import '../player_screen.dart'; +import 'channel_tile.dart'; -class ChannelList extends ConsumerWidget { +class ChannelList extends ConsumerStatefulWidget { final TextEditingController m3uController; const ChannelList({super.key, required this.m3uController}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ChannelListState(); +} + +class _ChannelListState extends ConsumerState { + String _query = ''; + + @override + Widget build(BuildContext context) { final state = ref.watch(channelListProvider); final selected = ref.watch(selectedChannelProvider); return Container( - width: 320, + width: 360, decoration: BoxDecoration(color: Colors.grey[900]), child: Column(children: [ Padding( padding: const EdgeInsets.all(12.0), child: Row(children: [ - const Expanded( - child: Text('실시간 채널', - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('실시간 채널', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + TextField( + onChanged: (v) => setState(() => _query = v), + decoration: InputDecoration( + hintText: 'Search channels', + filled: true, + fillColor: Colors.grey[850], + prefixIcon: + const Icon(Icons.search, color: Colors.white70), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none), + ), + ) + ])), IconButton( onPressed: () => ref .read(channelListProvider.notifier) - .fetch(m3uController.text.trim()), + .fetch(widget.m3uController.text.trim()), icon: const Icon(Icons.refresh)), ]), ), @@ -36,29 +62,28 @@ class ChannelList extends ConsumerWidget { Expanded( child: state.when( data: (list) { - if (list.isEmpty) - return const Center(child: Text('채널이 없습니다. M3U를 불러오세요.')); - return ListView.separated( - itemCount: list.length, - separatorBuilder: (_, __) => const Divider(height: 1), + final filtered = list + .where((s) => + s.title.toLowerCase().contains(_query.toLowerCase()) || + s.url.toLowerCase().contains(_query.toLowerCase())) + .toList(); + if (filtered.isEmpty) + return const Center(child: Text('채널이 없습니다. 검색어를 변경해보세요.')); + return ListView.builder( + itemCount: filtered.length, itemBuilder: (context, idx) { - final s = list[idx]; + final s = filtered[idx]; final isSelected = selected?.url == s.url; - return ListTile( + return ChannelTile( + entry: s, selected: isSelected, - title: Text(s.title, overflow: TextOverflow.ellipsis), - subtitle: Text(s.url, - overflow: TextOverflow.ellipsis, maxLines: 1), onTap: () => ref.read(selectedChannelProvider.notifier).state = s, - trailing: IconButton( - icon: const Icon(Icons.open_in_full), - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PlayerScreen( - videoPath: s.url, videoName: s.title))), - ), + onOpen: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlayerScreen( + videoPath: s.url, videoName: s.title))), ); }, ); @@ -71,7 +96,7 @@ class ChannelList extends ConsumerWidget { ElevatedButton( onPressed: () => ref .read(channelListProvider.notifier) - .fetch(m3uController.text.trim()), + .fetch(widget.m3uController.text.trim()), child: const Text('다시시도')) ])), ), diff --git a/lib/widgets/channel_tile.dart b/lib/widgets/channel_tile.dart new file mode 100644 index 0000000..7a2c93b --- /dev/null +++ b/lib/widgets/channel_tile.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../models/stream_entry.dart'; + +class ChannelTile extends StatelessWidget { + final StreamEntry entry; + final bool selected; + final VoidCallback? onTap; + final VoidCallback? onOpen; + + const ChannelTile( + {super.key, + required this.entry, + this.selected = false, + this.onTap, + this.onOpen}); + + @override + Widget build(BuildContext context) { + final border = selected + ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) + : null; + + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), side: BorderSide.none), + color: selected ? Colors.grey[850] : Colors.transparent, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: border, borderRadius: BorderRadius.circular(10)), + child: Row(children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 56, + height: 40, + child: entry.logo != null + ? CachedNetworkImage( + imageUrl: entry.logo!, + fit: BoxFit.cover, + placeholder: (c, s) => + Container(color: Colors.grey[800]), + errorWidget: (c, s, e) => Container( + color: Colors.grey[800], + child: const Icon(Icons.tv, color: Colors.white30)), + ) + : Container( + color: Colors.grey[800], + child: const Icon(Icons.tv, color: Colors.white30)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white)), + const SizedBox(height: 4), + Text(entry.url, + style: TextStyle(fontSize: 11, color: Colors.white70), + overflow: TextOverflow.ellipsis, + maxLines: 1), + ])), + IconButton( + icon: const Icon(Icons.open_in_new, color: Colors.white70), + onPressed: onOpen, + tooltip: 'Open full screen') + ]), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 168e54a..2b41a0c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import media_kit_video import package_info_plus import path_provider_foundation import shared_preferences_foundation +import sqflite_darwin import volume_controller import wakelock_plus @@ -23,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index da9ac38..3730dc2 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -13,6 +13,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - volume_controller (0.0.1): - FlutterMacOS - wakelock_plus (0.0.1): @@ -26,6 +29,7 @@ DEPENDENCIES: - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) @@ -44,6 +48,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin volume_controller: :path: Flutter/ephemeral/.symlinks/plugins/volume_controller/macos wakelock_plus: @@ -57,6 +63,7 @@ SPEC CHECKSUMS: media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b diff --git a/pubspec.lock b/pubspec.lock index 9733140..c8687c0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -150,6 +174,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -336,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_info_plus: dependency: transitive description: @@ -448,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" safe_local_storage: dependency: transitive description: @@ -541,6 +589,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ad44775..684e20d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: http: ^1.6.0 flutter_riverpod: ^2.3.0 shared_preferences: ^2.5.4 + cached_network_image: ^3.2.3 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 33d62e2..c099fd4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -18,6 +18,7 @@ void main() { // Verify HomeScreen has main controls (M3U URL input) expect(find.text('M3U URL'), findsOneWidget); - expect(find.byType(TextField), findsOneWidget); + // We now have a search box + M3U input; ensure at least one TextField exists + expect(find.byType(TextField), findsWidgets); }); }