Files
yommi_ff/lib/main.dart
2026-01-11 19:49:43 +09:00

333 lines
11 KiB
Dart

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),
)
]),
),
],
),
);
}
}