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 createState() => _HomeScreenState(); } class _HomeScreenState extends ConsumerState { 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 _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 _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 _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 _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 _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( 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), ) ]), ), ], ), ); } }