Initial commit
This commit is contained in:
332
lib/main.dart
Normal file
332
lib/main.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:charset_converter/charset_converter.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'providers/m3u_provider.dart';
|
||||
import 'widgets/channel_list.dart';
|
||||
import 'widgets/m3u_sources_screen.dart';
|
||||
import 'providers/m3u_sources_provider.dart';
|
||||
import 'player_screen.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
MediaKit.ensureInitialized();
|
||||
runApp(ProviderScope(child: const MyApp()));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'yommi Player',
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorSchemeSeed: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
bool _dragging = false;
|
||||
|
||||
final TextEditingController _m3uController = TextEditingController(
|
||||
text: 'https://ff.yommi.duckdns.org/alive/api/m3u?apikey=R7CEKQAANR');
|
||||
|
||||
// Full Disk Access prompt control (macOS): if true, banner will show on startup
|
||||
bool _showFdaPrompt = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_maybeShowFdaPrompt();
|
||||
|
||||
// Load saved M3U sources and auto-fill default if present
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
await ref.read(m3uSourcesProvider.notifier).load();
|
||||
final def = ref.read(m3uSourcesProvider).defaultUrl;
|
||||
if (def != null && def.isNotEmpty) {
|
||||
_m3uController.text = def;
|
||||
// Auto-fetch default source on startup
|
||||
ref.read(channelListProvider.notifier).fetch(def.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load saved M3U sources: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _maybeShowFdaPrompt() async {
|
||||
if (!Platform.isMacOS) return;
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final flagFile = File('${dir.path}/suppress_fda_prompt');
|
||||
final suppressed = await flagFile.exists();
|
||||
if (!suppressed) {
|
||||
// Show banner after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).clearMaterialBanners();
|
||||
ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner(
|
||||
content: Row(children: const [
|
||||
Icon(Icons.lock, size: 36, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'앱이 외장 드라이브나 보호된 폴더의 자막에 접근하려면 전체 디스크 접근 권한이 필요할 수 있습니다.'))
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _openPrivacySettings();
|
||||
},
|
||||
child: const Text('권한 부여'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
|
||||
},
|
||||
child: const Text('다음에 알림'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await flagFile.writeAsString('1');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to persist suppress flag: $e');
|
||||
}
|
||||
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
|
||||
},
|
||||
child: const Text('다시 보지 않기'),
|
||||
),
|
||||
],
|
||||
));
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to check or show FDA prompt: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPrivacySettings() async {
|
||||
try {
|
||||
await Process.run('open', [
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'
|
||||
]);
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('시스템 환경설정이 열렸습니다. 파일 및 폴더 권한을 확인하세요.')));
|
||||
} catch (e) {
|
||||
debugPrint('Failed to open Privacy settings: $e');
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('시스템 설정을 열지 못했습니다. 수동으로 열어주세요.')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playFile(BuildContext context, String path) async {
|
||||
final videoPath = path;
|
||||
final videoName = path.split(Platform.pathSeparator).last;
|
||||
|
||||
String? subtitlePath;
|
||||
String? subtitleContent;
|
||||
final pathWithoutExtension =
|
||||
videoPath.substring(0, videoPath.lastIndexOf('.'));
|
||||
final potentialSubtitles = ['.srt', '.vtt', '.ass'];
|
||||
|
||||
for (final ext in potentialSubtitles) {
|
||||
final potentialPath = '$pathWithoutExtension$ext';
|
||||
if (await File(potentialPath).exists()) {
|
||||
subtitlePath = potentialPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitlePath != null) {
|
||||
subtitleContent = await _readSubtitleFileContent(subtitlePath);
|
||||
if (subtitleContent == null) {
|
||||
debugPrint('자막을 읽을 수 없습니다: $subtitlePath');
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlayerScreen(
|
||||
videoPath: videoPath,
|
||||
videoName: videoName,
|
||||
subtitleContent: subtitleContent,
|
||||
subtitlePath: subtitlePath,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickAndPlay(BuildContext context) async {
|
||||
try {
|
||||
FilePickerResult? result =
|
||||
await FilePicker.platform.pickFiles(type: FileType.video);
|
||||
if (result != null && result.files.single.path != null) {
|
||||
_playFile(context, result.files.single.path!);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('파일 선택 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Try reading subtitle file with multiple fallbacks (UTF-8, then common Korean 'euc-kr')
|
||||
Future<String?> _readSubtitleFileContent(String path) async {
|
||||
try {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
|
||||
// First try explicit UTF-8 decoding (most SRTs are UTF-8 or UTF-8 with BOM)
|
||||
try {
|
||||
final s = utf8.decode(bytes);
|
||||
// If decode succeeded and content looks valid, return it
|
||||
if (!s.contains('\uFFFD')) {
|
||||
return s.replaceFirst('\uFEFF', ''); // strip BOM if present
|
||||
}
|
||||
} catch (_) {
|
||||
// fall through to alternate decoders
|
||||
}
|
||||
|
||||
// Try EUC-KR (CP949) — common for Korean SRT files
|
||||
try {
|
||||
final s = await CharsetConverter.decode('euc-kr', bytes);
|
||||
return s.replaceFirst('\uFEFF', '');
|
||||
} catch (e) {
|
||||
debugPrint('euc-kr decoding failed: $e');
|
||||
}
|
||||
|
||||
// Last resort: try latin1 to preserve bytes (may be mangled for non-latin scripts)
|
||||
try {
|
||||
return latin1.decode(bytes);
|
||||
} catch (e) {
|
||||
debugPrint('latin1 decoding failed: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('자막 파일 읽기 실패 전체: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedStream = ref.watch(selectedChannelProvider);
|
||||
final channelState = ref.watch(channelListProvider);
|
||||
final isLoading = channelState is AsyncLoading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('yommi Player')),
|
||||
body: Column(
|
||||
children: [
|
||||
// M3U fetch controls
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _m3uController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'M3U URL', border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => ref
|
||||
.read(channelListProvider.notifier)
|
||||
.fetch(_m3uController.text.trim()),
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh),
|
||||
label: const Text('불러오기'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: () => ref
|
||||
.read(channelListProvider.notifier)
|
||||
.fetch(_m3uController.text.trim()),
|
||||
icon: const Icon(Icons.refresh)),
|
||||
const SizedBox(width: 8),
|
||||
// Open saved M3U sources screen
|
||||
IconButton(
|
||||
tooltip: 'Saved sources',
|
||||
onPressed: () async {
|
||||
final selected = await Navigator.push<String?>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const M3uSourcesScreen()));
|
||||
if (selected != null && selected.isNotEmpty) {
|
||||
_m3uController.text = selected;
|
||||
ref
|
||||
.read(channelListProvider.notifier)
|
||||
.fetch(selected.trim());
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.list_alt)),
|
||||
]),
|
||||
),
|
||||
|
||||
// Main area: sidebar + player
|
||||
Expanded(
|
||||
child: Row(children: [
|
||||
ChannelList(m3uController: _m3uController),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: selectedStream == null
|
||||
? DropTarget(
|
||||
onDragDone: (detail) {
|
||||
final path = detail.files.first.path;
|
||||
if (path.endsWith('.mp4') ||
|
||||
path.endsWith('.mkv') ||
|
||||
path.endsWith('.avi')) {
|
||||
_playFile(context, path);
|
||||
}
|
||||
},
|
||||
onDragEntered: (d) => setState(() => _dragging = true),
|
||||
onDragExited: (d) => setState(() => _dragging = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
color: _dragging
|
||||
? Colors.blue.withOpacity(0.08)
|
||||
: Colors.transparent,
|
||||
child: const Center(
|
||||
child: Text('왼쪽에서 채널을 선택하거나 비디오를 드래그하세요')),
|
||||
),
|
||||
)
|
||||
: PlayerView(
|
||||
videoPath: selectedStream.url,
|
||||
videoName: selectedStream.title,
|
||||
inlineMode: true),
|
||||
)
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/models/stream_entry.dart
Normal file
20
lib/models/stream_entry.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
class StreamEntry {
|
||||
final String title;
|
||||
final String url;
|
||||
|
||||
StreamEntry({required this.title, required this.url});
|
||||
|
||||
@override
|
||||
String toString() => 'StreamEntry(title: $title, url: $url)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is StreamEntry &&
|
||||
runtimeType == other.runtimeType &&
|
||||
title == other.title &&
|
||||
url == other.url;
|
||||
|
||||
@override
|
||||
int get hashCode => title.hashCode ^ url.hashCode;
|
||||
}
|
||||
1256
lib/player_screen.dart
Normal file
1256
lib/player_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
31
lib/providers/m3u_provider.dart
Normal file
31
lib/providers/m3u_provider.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/stream_entry.dart';
|
||||
import '../services/m3u_service.dart';
|
||||
|
||||
final m3uServiceProvider = Provider<M3UService>((ref) => M3UService());
|
||||
|
||||
final channelListProvider =
|
||||
StateNotifierProvider<ChannelListNotifier, AsyncValue<List<StreamEntry>>>(
|
||||
(ref) {
|
||||
final svc = ref.watch(m3uServiceProvider);
|
||||
return ChannelListNotifier(svc);
|
||||
});
|
||||
|
||||
final selectedChannelProvider = StateProvider<StreamEntry?>((ref) => null);
|
||||
|
||||
class ChannelListNotifier extends StateNotifier<AsyncValue<List<StreamEntry>>> {
|
||||
final M3UService _svc;
|
||||
ChannelListNotifier(this._svc) : super(const AsyncValue.data([]));
|
||||
|
||||
Future<void> fetch(String url, {bool force = false}) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final entries = await _svc.fetch(url, forceRefresh: force);
|
||||
state = AsyncValue.data(entries);
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh(String url) async => fetch(url, force: true);
|
||||
}
|
||||
66
lib/providers/m3u_sources_provider.dart
Normal file
66
lib/providers/m3u_sources_provider.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class M3uSourcesState {
|
||||
final List<String> sources;
|
||||
final String? defaultUrl;
|
||||
|
||||
M3uSourcesState({required this.sources, this.defaultUrl});
|
||||
|
||||
M3uSourcesState copyWith({List<String>? sources, String? defaultUrl}) =>
|
||||
M3uSourcesState(
|
||||
sources: sources ?? this.sources,
|
||||
defaultUrl: defaultUrl ?? this.defaultUrl,
|
||||
);
|
||||
}
|
||||
|
||||
class M3uSourcesNotifier extends StateNotifier<M3uSourcesState> {
|
||||
static const _kSourcesKey = 'm3u_sources';
|
||||
static const _kDefaultKey = 'm3u_default';
|
||||
|
||||
M3uSourcesNotifier() : super(M3uSourcesState(sources: [], defaultUrl: null));
|
||||
|
||||
Future<void> load() async {
|
||||
final sp = await SharedPreferences.getInstance();
|
||||
final list = sp.getStringList(_kSourcesKey) ?? <String>[];
|
||||
final def = sp.getString(_kDefaultKey);
|
||||
state = state.copyWith(sources: list, defaultUrl: def);
|
||||
}
|
||||
|
||||
Future<void> add(String url) async {
|
||||
final u = url.trim();
|
||||
if (u.isEmpty) return;
|
||||
if (state.sources.contains(u)) return;
|
||||
final updated = [...state.sources, u];
|
||||
state = state.copyWith(sources: updated);
|
||||
final sp = await SharedPreferences.getInstance();
|
||||
await sp.setStringList(_kSourcesKey, updated);
|
||||
}
|
||||
|
||||
Future<void> remove(String url) async {
|
||||
final updated = state.sources.where((s) => s != url).toList();
|
||||
String? def = state.defaultUrl;
|
||||
if (def == url) def = null;
|
||||
state = state.copyWith(sources: updated, defaultUrl: def);
|
||||
final sp = await SharedPreferences.getInstance();
|
||||
await sp.setStringList(_kSourcesKey, updated);
|
||||
if (def == null)
|
||||
await sp.remove(_kDefaultKey);
|
||||
else
|
||||
await sp.setString(_kDefaultKey, def);
|
||||
}
|
||||
|
||||
Future<void> setDefault(String? url) async {
|
||||
state = state.copyWith(defaultUrl: url);
|
||||
final sp = await SharedPreferences.getInstance();
|
||||
if (url == null)
|
||||
await sp.remove(_kDefaultKey);
|
||||
else
|
||||
await sp.setString(_kDefaultKey, url);
|
||||
}
|
||||
}
|
||||
|
||||
final m3uSourcesProvider =
|
||||
StateNotifierProvider<M3uSourcesNotifier, M3uSourcesState>((ref) {
|
||||
return M3uSourcesNotifier();
|
||||
});
|
||||
91
lib/services/m3u_service.dart
Normal file
91
lib/services/m3u_service.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:meta/meta.dart';
|
||||
import '../models/stream_entry.dart';
|
||||
|
||||
class M3uParseException implements Exception {
|
||||
final String message;
|
||||
M3uParseException(this.message);
|
||||
@override
|
||||
String toString() => 'M3uParseException: $message';
|
||||
}
|
||||
|
||||
class M3UService {
|
||||
final http.Client _client;
|
||||
final Duration cacheTTL;
|
||||
|
||||
String? _cachedUrl;
|
||||
DateTime? _cachedAt;
|
||||
List<StreamEntry>? _cachedEntries;
|
||||
|
||||
M3UService({http.Client? client, this.cacheTTL = const Duration(minutes: 5)})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
/// Fetches an M3U (over HTTP) and returns parsed list of [StreamEntry].
|
||||
/// Uses a short in-memory cache to avoid repeated network calls.
|
||||
Future<List<StreamEntry>> fetch(String url,
|
||||
{bool forceRefresh = false}) async {
|
||||
if (!forceRefresh &&
|
||||
_cachedUrl == url &&
|
||||
_cachedEntries != null &&
|
||||
_cachedAt != null &&
|
||||
DateTime.now().difference(_cachedAt!) < cacheTTL) {
|
||||
return _cachedEntries!;
|
||||
}
|
||||
|
||||
final resp = await _client.get(Uri.parse(url));
|
||||
if (resp.statusCode != 200) {
|
||||
throw Exception('Failed to fetch M3U: HTTP ${resp.statusCode}');
|
||||
}
|
||||
|
||||
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.
|
||||
List<StreamEntry> parse(String content) {
|
||||
final lines = content.replaceAll('\r', '').split('\n');
|
||||
final entries = <StreamEntry>[];
|
||||
|
||||
String? pendingTitle;
|
||||
|
||||
for (var raw in lines) {
|
||||
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
|
||||
final parts = line.split(',');
|
||||
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();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('#')) continue;
|
||||
|
||||
// At this point 'line' is a URL
|
||||
final url = line;
|
||||
final title = pendingTitle ?? url;
|
||||
entries.add(StreamEntry(title: title, url: url));
|
||||
pendingTitle = null;
|
||||
}
|
||||
|
||||
if (entries.isEmpty) {
|
||||
throw M3uParseException('No entries found in M3U');
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
82
lib/widgets/channel_list.dart
Normal file
82
lib/widgets/channel_list.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/stream_entry.dart';
|
||||
import '../providers/m3u_provider.dart';
|
||||
import '../player_screen.dart';
|
||||
|
||||
class ChannelList extends ConsumerWidget {
|
||||
final TextEditingController m3uController;
|
||||
|
||||
const ChannelList({super.key, required this.m3uController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(channelListProvider);
|
||||
final selected = ref.watch(selectedChannelProvider);
|
||||
|
||||
return Container(
|
||||
width: 320,
|
||||
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))),
|
||||
IconButton(
|
||||
onPressed: () => ref
|
||||
.read(channelListProvider.notifier)
|
||||
.fetch(m3uController.text.trim()),
|
||||
icon: const Icon(Icons.refresh)),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
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),
|
||||
itemBuilder: (context, idx) {
|
||||
final s = list[idx];
|
||||
final isSelected = selected?.url == s.url;
|
||||
return ListTile(
|
||||
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))),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('불러오기 실패: $e'),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref
|
||||
.read(channelListProvider.notifier)
|
||||
.fetch(m3uController.text.trim()),
|
||||
child: const Text('다시시도'))
|
||||
])),
|
||||
),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/widgets/m3u_sources_screen.dart
Normal file
103
lib/widgets/m3u_sources_screen.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/m3u_sources_provider.dart';
|
||||
|
||||
class M3uSourcesScreen extends ConsumerStatefulWidget {
|
||||
const M3uSourcesScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<M3uSourcesScreen> createState() => _M3uSourcesScreenState();
|
||||
}
|
||||
|
||||
class _M3uSourcesScreenState extends ConsumerState<M3uSourcesScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// load persisted sources
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(m3uSourcesProvider.notifier).load();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(m3uSourcesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('M3U Sources')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'M3U URL',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final url = _controller.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
await ref.read(m3uSourcesProvider.notifier).add(url);
|
||||
_controller.clear();
|
||||
},
|
||||
child: const Text('추가'))
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: state.sources.isEmpty
|
||||
? const Center(child: Text('저장된 M3U 소스가 없습니다.'))
|
||||
: ListView.separated(
|
||||
itemCount: state.sources.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, idx) {
|
||||
final url = state.sources[idx];
|
||||
final isDefault = url == state.defaultUrl;
|
||||
return ListTile(
|
||||
title:
|
||||
Text(url, style: const TextStyle(fontSize: 14)),
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
isDefault ? Icons.star : Icons.star_border),
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(m3uSourcesProvider.notifier)
|
||||
.setDefault(isDefault ? null : url);
|
||||
},
|
||||
tooltip:
|
||||
isDefault ? 'Default source' : 'Set default',
|
||||
),
|
||||
trailing:
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(m3uSourcesProvider.notifier)
|
||||
.remove(url);
|
||||
},
|
||||
)
|
||||
]),
|
||||
onTap: () {
|
||||
// Close and return the selected URL
|
||||
Navigator.of(context).pop(url);
|
||||
},
|
||||
);
|
||||
})),
|
||||
const SizedBox(height: 8),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('닫기'))
|
||||
])
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user