333 lines
11 KiB
Dart
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),
|
|
)
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|