diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index c02810a..b81745b 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -10,6 +10,7 @@ import '../core/charset_detector.dart'; import '../core/encode_options.dart'; import '../core/languages.dart'; import '../core/mkv_command_builder.dart'; +import '../core/vfs.dart'; import '../core/webdav.dart'; import '../core/models.dart'; import '../core/output_paths.dart'; @@ -1125,7 +1126,8 @@ class AppController extends ChangeNotifier { final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); final program = settings.externalPlayers[ext]; if (!internal && program != null && program.isNotEmpty) { - await services.shell.openExternal(program, videos); + // WebDAV 는 스트리밍 주소 (아이디 포함) 로 넘긴다 + await services.shell.openExternal(program, videos.map(vPlayable).toList()); _log(trf('외부 프로그램으로 재생: {0} ← {1}개', [program == 'system' ? tr('기본 연결 프로그램') : p.basename(program), videos.length])); return null; } @@ -1133,7 +1135,9 @@ class AppController extends ChangeNotifier { if (!keepOrder) videos.sort((a, b) => naturalCompare(p.basename(a), p.basename(b))); return (videos, 0); } - final siblings = await services.storage.listFiles(p.dirname(videos.first)); + final siblings = isDav(videos.first) + ? [for (final e in await vList(vDirname(videos.first))) if (!e.isDir) e.path] + : await services.storage.listFiles(p.dirname(videos.first)); return buildPlaylist(videos.first, siblings, settings.playlistMode); } @@ -1167,6 +1171,17 @@ class AppController extends ChangeNotifier { Future> externalSubtitlesFor(String video) async { final out = []; final storage = services.storage; + if (isDav(video)) { + // WebDAV: 같은 폴더의 자막만 (작으므로 임시 폴더로 받아서 넘긴다) + try { + final files = [for (final e in await vList(vDirname(video))) if (!e.isDir) e.path]; + final temp = await storage.tempDirectory(); + for (final d in findSiblingSubtitles(video, files)) { + out.add(await vLocalCopy(d.path, temp)); + } + } catch (_) {} + return out; + } out.addAll(findSiblingSubtitles(video, await storage.listFiles(p.dirname(video))).map((d) => d.path)); final base = p.basenameWithoutExtension(video).toLowerCase(); for (final f in await storage.listFiles(outputDirFor(video))) { diff --git a/lib/core/vfs.dart b/lib/core/vfs.dart index 9b19e0d..d8c32f8 100644 --- a/lib/core/vfs.dart +++ b/lib/core/vfs.dart @@ -37,6 +37,27 @@ class DavPath { DavClient get client => DavRegistry.client(server); } +/// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더. [withUserInfo] 면 주소에 아이디:비밀번호를 넣는다 +/// (헤더를 넘길 수 없는 외부 프로그램용). +({String url, Map headers, bool insecure}) davStream(String path, {bool withUserInfo = false}) { + final d = DavPath.parse(path); + final server = DavRegistry.server(d.server); + final client = d.client; + var uri = client.uriOf(d.rel); + if (withUserInfo && server != null && server.user.isNotEmpty) { + uri = uri.replace(userInfo: '${Uri.encodeComponent(server.user)}:${Uri.encodeComponent(server.password)}'); + } + final auth = client.authHeader; + return ( + url: uri.toString(), + headers: {'Authorization': ?auth}, + insecure: server?.insecure ?? false, + ); +} + +/// 플레이어 · 외부 프로그램에 넘길 주소: WebDAV 는 스트리밍 주소 (아이디 포함), 로컬은 그대로 +String vPlayable(String path) => isDav(path) ? davStream(path, withUserInfo: true).url : path; + /// 경로를 한 모양으로 (dav:// 만 정리, 로컬은 그대로) String vNorm(String path) => isDav(path) ? DavPath.parse(path).full : path; String vJoin(String dir, String name) => isDav(dir) ? DavPath.parse(dir).child(name).full : p.join(dir, name); diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart index 4d60183..0410272 100644 --- a/lib/core/webdav.dart +++ b/lib/core/webdav.dart @@ -91,6 +91,9 @@ class DavClient { return _base.replace(path: path); } + /// Authorization 헤더 값 (아이디 · 비밀번호가 없으면 null). 플레이어가 스트리밍할 때도 쓴다. + String? get authHeader => _auth; + String? get _auth => server.user.isEmpty && server.password.isEmpty ? null : 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}'; diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index 11dea69..90eb492 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -5,6 +5,7 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:media_kit_video/media_kit_video.dart'; import 'package:path/path.dart' as p; +import '../../core/vfs.dart'; import '../../services/media_player.dart'; import '../../l10n/tr.dart'; @@ -104,7 +105,7 @@ class MediaKitFullPlayer implements MediaPlayer { Future open(List files, {int start = 0}) async { _files = List.of(files); _externalSub = null; - await _p.open(mk.Playlist([for (final f in files) mk.Media(f)], index: start)); + await _p.open(mk.Playlist([for (final f in files) await _media(f)], index: start)); } @override @@ -112,11 +113,22 @@ class MediaKitFullPlayer implements MediaPlayer { if (_files.isEmpty) return open(files); for (final f in files) { _files.add(f); - await _p.add(mk.Media(f)); + await _p.add(await _media(f)); } _emit(); } + /// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함) + Future _media(String f) async { + if (!isDav(f)) return mk.Media(f); + final s = davStream(f); + if (s.insecure) { + final native = _p.platform; + if (native is mk.NativePlayer) await native.setProperty('tls-verify', 'no'); + } + return mk.Media(s.url, httpHeaders: s.headers); + } + @override Future jump(int index) => _p.jump(index); @override diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ddfc6d0..9fe6240 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -575,16 +575,10 @@ class _ExplorerPageState extends State { } } - /// 동영상 재생 (WebDAV 는 받은 뒤) + /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) Future _play(List paths, {bool internal = false, bool keepOrder = false}) async { - final local = []; - for (final x in paths) { - final l = await _fetch(x); - if (l == null) return; - local.add(l); - } - if (!mounted || local.isEmpty) return; - await playFiles(context, c, local, internal: internal, keepOrder: keepOrder); + if (!mounted || paths.isEmpty) return; + await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder); } Future _menu(_Pane pane, FileEntry e, Offset at) async { @@ -597,7 +591,7 @@ class _ExplorerPageState extends State { final items = <(String, IconData, String)>[ if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), - if (e.isDir && !dav) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), + if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index e00b27f..94dfd37 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart'; import '../app/app_controller.dart'; import '../core/playlist.dart'; +import '../core/vfs.dart'; import '../core/srt.dart' show formatSrtTime; import '../services/media_player.dart'; import 'app_actions.dart'; @@ -377,8 +378,8 @@ class _PlayerPageState extends State { ], child: Text(tr('재생 속도'))), item(tr('재생 목록'), () => setState(() => _showList = !_showList), key: 'L', checked: _showList), const Divider(height: 1), - if (vlc != null) item(tr('VLC 로 열기'), () => c.services.shell.openExternal(vlc, [_currentFile])), - item(tr('기본 프로그램으로 열기'), () => c.services.shell.openExternal('system', [_currentFile])), + if (vlc != null) item(tr('VLC 로 열기'), () => c.services.shell.openExternal(vlc, [vPlayable(_currentFile)])), + item(tr('기본 프로그램으로 열기'), () => c.services.shell.openExternal('system', [vPlayable(_currentFile)])), ]; } diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 12235a0..e0667e0 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -5,6 +5,7 @@ import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/core/webdav.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; @@ -159,4 +160,32 @@ void main() { expect(live.pending[LiveSync.keyOf(pair)], isEmpty); live.dispose(); }); + + test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async { + rf('영상/1화.mkv', 'v1'); + rf('영상/2화.mkv', 'v2'); + rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); + rf('영상/메모.txt', 't'); + final s = davStream('dav://$id/영상/1화.mkv'); + expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}'); + expect(s.headers['Authorization'], startsWith('Basic ')); + expect(vPlayable('dav://$id/영상/1화.mkv'), startsWith('http://user:pass@127.0.0.1:')); + expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv'); + // 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청) + final http = HttpClient(); + final req = await http.getUrl(Uri.parse(s.url)); + s.headers.forEach(req.headers.set); + final res = await req.close(); + expect(await res.transform(const SystemEncoding().decoder).join(), 'v1'); + http.close(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.playlistMode = PlaylistMode.folder; + final before = server.requests.where((r) => r.startsWith('GET')).length; + final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']); + expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']); + expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음 + final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv'); + expect(subs.map(p.basename), ['1화.ko.srt']); + expect(File(subs.single).readAsStringSync(), contains('안녕')); + }); }