Initial commit

This commit is contained in:
2026-01-11 19:49:43 +09:00
commit 9cf16ce279
140 changed files with 7562 additions and 0 deletions

332
lib/main.dart Normal file
View 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),
)
]),
),
],
),
);
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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);
}

View 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();
});

View 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;
}
}

View 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('다시시도'))
])),
),
)
]),
);
}
}

View 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('닫기'))
])
]),
),
);
}
}