WebDAV 동영상: 받지 않고 바로 스트리밍 재생
- 플레이어 (mpv) 에 주소 + 인증 헤더로 넘김 (자체 서명 인증서면 tls-verify=no), 건너뛰기는 서버 Range 요청 - 같은 폴더 동영상으로 재생 목록, 같은 폴더 자막은 작으니 임시로 받아서 - 외부 플레이어 · VLC · 기본 프로그램에는 아이디를 넣은 주소로 - 탐색기의 "이 폴더의 동영상 재생" 도 WebDAV 에서 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
8f779fa7e6
commit
b3707e2ce5
|
|
@ -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<List<String>> externalSubtitlesFor(String video) async {
|
||||
final out = <String>[];
|
||||
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))) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,27 @@ class DavPath {
|
|||
DavClient get client => DavRegistry.client(server);
|
||||
}
|
||||
|
||||
/// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더. [withUserInfo] 면 주소에 아이디:비밀번호를 넣는다
|
||||
/// (헤더를 넘길 수 없는 외부 프로그램용).
|
||||
({String url, Map<String, String> 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);
|
||||
|
|
|
|||
|
|
@ -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}'))}';
|
||||
|
|
|
|||
|
|
@ -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<void> open(List<String> 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<mk.Media> _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<void> jump(int index) => _p.jump(index);
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -575,16 +575,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 동영상 재생 (WebDAV 는 받은 뒤)
|
||||
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
|
||||
Future<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
|
||||
final local = <String>[];
|
||||
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<void> _menu(_Pane pane, FileEntry e, Offset at) async {
|
||||
|
|
@ -597,7 +591,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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('기본 앱으로 열기')),
|
||||
|
|
|
|||
|
|
@ -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<PlayerPage> {
|
|||
], 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)])),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('안녕'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue