diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3f335..2952284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## v1.3.0 + +### Changelog + +* Add [FVP](https://github.com/wang-bin/fvp) player backend (Experimental, with unknown bugs) +* Adding volume adjust +* Add file sort +* Add hotkeys: Volume up ( `Arrow Up` ), Volume down ( `Arrow Down` ), Volume mute ( `Ctrl + M` ), Toggle always on top ( `F10` ), Close currently media file ( `Ctrl + C` ), Exit application ( `Alt + X` ) +* Improved some visual effects + +### 更新日志 +* 添加 [FVP](https://github.com/wang-bin/fvp) 播放器后端(实验性,有未知bug) +* 添加音量调整 +* 添加文件排序 +* 添加快捷键:提升音量( `Arrow Up` )、降低音量( `Arrow Down` )、静音(`Ctrl + M`)、切换窗口置顶( `F10` )、关闭当前媒体文件( `Ctrl + C` )、退出应用( `Alt + X` ) +* 改进了部分视觉效果 + + ## v1.2.1 ### Changelog diff --git a/README.md b/README.md index 30898b3..13e97a7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ English | [中文](./README_CN.md) ## Features -- [x] Base on [media-kit](https://github.com/media-kit/media-kit) supports multiple video formats +- [x] Base on [Media Kit](https://github.com/media-kit/media-kit) | [FVP](https://github.com/wang-bin/fvp), supports multiple video formats - [x] Local storage and WebDAV support - [x] Switchable subtitle and audio track - [x] Playback queue support for random and repeat @@ -33,37 +33,43 @@ English | [中文](./README_CN.md) ### Keyboard Controls | Key | Description | |----------------------|------------------------------------------| -| `Space` | Play / Pause / Select File | -| `Enter` | Enter Full Screen / Exit Full Screen / Select File | -| `F11` | Enter Full Screen / Exit Full Screen | +| `Space` | Play / Pause / Select file | | `Arrow Left` | Fast backward 10 seconds | | `Arrow Right` | Fast forward 10 seconds | +| `Arrow Up` | Volume up | +| `Arrow Down` | Volume down | | `Ctrl + Arrow Left` | Previous | | `Ctrl + Arrow Right` | Next | | `Ctrl + X` | Shuffle | | `Ctrl + R` | Repeat | -| `Ctrl + V` | Video Zoom | -| `F` | Save | -| `P` | Play Queue | -| `S` | Subtitles and Audio Tracks | -| `Ctrl + O` | Open File | -| `Ctrl + L` | Open Link | -| `Ctrl + H` | Play History | +| `Ctrl + V` | Video zoom | +| `Ctrl + M` | Volume mute | +| `S` | Subtitles and audio tracks | +| `P` | Play queue | +| `F` | Storages | +| `Ctrl + O` | Open file | +| `Ctrl + L` | Open link | +| `Ctrl + C` | Close currently media file | +| `Ctrl + H` | Play history | | `Ctrl + P` | Settings | -| `Esc` | Exit Current Menu / Go Back / Close Full Screen | +| `Enter` | Enter full screen / Exit full screen / Select file | +| `F11` | Enter full screen / Exit full screen | +| `Esc` | Exit current Menu / Go back / Exit full screen | +| `F10` | Toggle always on top | +| `Alt + X` | Exit application | ### Gesture Controls -| Gesture | Description | -|----------------------|------------------------------------------| -| Tap | Select an item or open a menu | -| Double Tap Center | Play / Pause | -| Double Tap Left Side | Fast backward 10 seconds | -| Double Tap Right Side | Fast forward 10 seconds | -| Swipe Left / Right | Adjust playback progress | -| Swipe Up / Down on Left Side | Adjust screen brightness | -| Swipe Up / Down on Right Side | Adjust device volume | -| Long Press | Start Speed Playback | -| Long Press and Swipe Left/Right | Adjust Speed Playback Speed | +| Gesture | Description | +|---------------------------------|------------------------------------------| +| Tap | Select an item or open a menu | +| Double tap center | Play / Pause | +| Double tap left side | Fast backward 10 seconds | +| Double tap right side | Fast forward 10 seconds | +| Swipe left / right | Adjust playback progress | +| Swipe up / down on left side | Adjust screen brightness | +| Swipe up / down on right side | Adjust device volume | +| Long press | Start speed playback | +| Long press and swipe left / right | Adjust speed playback speed | ## Contribution diff --git a/README_CN.md b/README_CN.md index 903ffd9..408e402 100644 --- a/README_CN.md +++ b/README_CN.md @@ -10,7 +10,7 @@ ## 特性 -- [x] 基于 [media-kit](https://github.com/media-kit/media-kit) 可播放多种视频格式 +- [x] 基于 [Media Kit](https://github.com/media-kit/media-kit) | [FVP](https://github.com/wang-bin/fvp),可播放多种视频格式 - [x] 支持本地存储、WebDAV - [x] 可切换字幕和音轨 - [x] 播放队列支持随机和重复 @@ -34,23 +34,29 @@ | 键位 | 描述 | |----------------------|----------------------------------------| | `Space` | 播放 / 暂停 / 选择文件 | -| `Enter` | 进入全屏 / 退出全屏 / 选择文件 | -| `F11` | 进入全屏 / 退出全屏 | | `Arrow Left` | 快退 10 秒 | | `Arrow Right` | 快进 10 秒 | +| `Arrow Up` | 提升音量 | +| `Arrow Down` | 降低音量 | | `Ctrl + Arrow Left` | 上一个 | | `Ctrl + Arrow Right` | 下一个 | | `Ctrl + X` | 随机 | | `Ctrl + R` | 重复 | | `Ctrl + V` | 视频缩放 | -| `F` | 存储 | -| `P` | 播放队列 | +| `Ctrl + M` | 静音 | | `S` | 字幕和音轨 | +| `P` | 播放队列 | +| `F` | 存储 | | `Ctrl + O` | 打开文件 | | `Ctrl + L` | 打开链接 | +| `Ctrl + C` | 关闭当前媒体文件 | | `Ctrl + H` | 播放历史 | | `Ctrl + P` | 设置 | +| `Enter` | 进入全屏 / 退出全屏 / 选择文件 | +| `F11` | 进入全屏 / 退出全屏 | | `Esc` | 退出当前菜单 / 返回上一级 / 关闭全屏 | +| `F10` | 切换窗口置顶 | +| `Alt + X` | 退出应用 | ### 手势操作 | 手势 | 描述 | diff --git a/android/.gitignore b/android/.gitignore index 55afd91..0b6d805 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +/app/src/main/assets/flutter_assets \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 8e3e2bd..d2b9f78 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,7 @@ +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest + plugins { id "com.android.application" id "kotlin-android" @@ -57,3 +61,42 @@ android { flutter { source = "../.." } + + +task downloadFiles(type: Exec) { + def filesToDownload = [ + [ + "url": "https://github.com/notofonts/noto-cjk/raw/refs/heads/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Medium.otf", + "md5": "58c83279d990b2cf88d40a0a34832e31", + "destination": file("./src/main/assets/flutter_assets/assets/fonts/NotoSansCJKsc-Medium.otf") + ] + ] + + filesToDownload.each { fileInfo -> + def destFile = fileInfo.destination + + if (destFile.exists()) { + def calculatedMD5 = MessageDigest.getInstance("MD5").digest(Files.readAllBytes(destFile.toPath())).encodeHex().toString() + + if (calculatedMD5 != fileInfo.md5) { + destFile.delete() + println "MD5 mismatch. File deleted: ${destFile}" + } + } + + if (!destFile.exists()) { + destFile.parentFile.mkdirs() + println "Downloading file from: ${fileInfo.url}" + destFile.withOutputStream { os -> + os << new URL(fileInfo.url).openStream() + } + + def calculatedMD5 = MessageDigest.getInstance("MD5").digest(Files.readAllBytes(destFile.toPath())).encodeHex().toString() + if (calculatedMD5 != fileInfo.md5) { + throw new GradleException("MD5 verification failed for ${destFile}") + } + } + } +} + +assemble.dependsOn(downloadFiles) \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4c3e843..17b5f00 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/lib/hooks/use_app_lifecycle.dart b/lib/hooks/use_app_lifecycle.dart new file mode 100644 index 0000000..cf8b4aa --- /dev/null +++ b/lib/hooks/use_app_lifecycle.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/utils/logger.dart'; + +void useAppLifecycle(MediaPlayer player) { + AppLifecycleState? appLifecycleState = useAppLifecycleState(); + + useEffect(() { + try { + if (appLifecycleState == AppLifecycleState.paused) { + logger('App lifecycle state: paused'); + player.saveProgress(); + } + } catch (e) { + logger('App lifecycle state error: $e'); + } + return; + }, [appLifecycleState]); +} diff --git a/lib/hooks/use_cover.dart b/lib/hooks/use_cover.dart new file mode 100644 index 0000000..e308e06 --- /dev/null +++ b/lib/hooks/use_cover.dart @@ -0,0 +1,62 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/models/file.dart'; +import 'package:iris/models/storages/storage.dart'; +import 'package:iris/store/use_play_queue_store.dart'; +import 'package:iris/store/use_storage_store.dart'; +import 'package:iris/utils/files_filter.dart'; + +FileItem? useCover(BuildContext context) { + final playQueue = + usePlayQueueStore().select(context, (state) => state.playQueue); + final currentIndex = + usePlayQueueStore().select(context, (state) => state.currentIndex); + + final int currentPlayIndex = useMemoized( + () => playQueue.indexWhere((element) => element.index == currentIndex), + [playQueue, currentIndex]); + + final PlayQueueItem? currentPlay = useMemoized( + () => playQueue.isEmpty || currentPlayIndex < 0 + ? null + : playQueue[currentPlayIndex], + [playQueue, currentPlayIndex]); + + final localStorages = + useStorageStore().select(context, (state) => state.localStorages); + final storages = useStorageStore().select(context, (state) => state.storages); + + final List dir = useMemoized( + () => currentPlay?.file == null || currentPlay!.file.path.isEmpty + ? [] + : ([...currentPlay.file.path]..removeLast()), + [currentPlay?.file], + ); + + final Storage? storage = useMemoized( + () => currentPlay?.file == null + ? null + : [...localStorages, ...storages].firstWhereOrNull( + (storage) => storage.id == currentPlay?.file.storageId), + [currentPlay?.file, localStorages, storages]); + + final getCover = useMemoized(() async { + if (currentPlay?.file.type != ContentType.audio) return null; + + final files = await storage?.getFiles(dir); + + if (files == null) return null; + + final images = filesFilter(files, [ContentType.image]); + + return images.firstWhereOrNull( + (image) => image.name.split('.').first.toLowerCase() == 'cover') ?? + images.firstOrNull; + }, [currentPlay?.file, dir]); + + final cover = useFuture(getCover).data; + + return cover; +} diff --git a/lib/hooks/use_fvp_player.dart b/lib/hooks/use_fvp_player.dart new file mode 100644 index 0000000..bc7eeba --- /dev/null +++ b/lib/hooks/use_fvp_player.dart @@ -0,0 +1,285 @@ +import 'dart:io'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:fvp/fvp.dart'; +import 'package:iris/models/file.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/models/progress.dart'; +import 'package:iris/models/store/app_state.dart'; +import 'package:iris/store/use_app_store.dart'; +import 'package:iris/store/use_history_store.dart'; +import 'package:iris/store/use_play_queue_store.dart'; +import 'package:iris/utils/check_data_source_type.dart'; +import 'package:iris/utils/logger.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +FvpPlayer useFvpPlayer(BuildContext context) { + final autoPlay = useAppStore().select(context, (state) => state.autoPlay); + final volume = useAppStore().select(context, (state) => state.volume); + final isMuted = useAppStore().select(context, (state) => state.isMuted); + final repeat = useAppStore().select(context, (state) => state.repeat); + final playQueue = + usePlayQueueStore().select(context, (state) => state.playQueue); + final currentIndex = + usePlayQueueStore().select(context, (state) => state.currentIndex); + final bool alwaysPlayFromBeginning = + useAppStore().select(context, (state) => state.alwaysPlayFromBeginning); + + final history = useHistoryStore().select(context, (state) => state.history); + + final looping = + useMemoized(() => repeat == Repeat.one ? true : false, [repeat]); + + final int currentPlayIndex = useMemoized( + () => playQueue.indexWhere((element) => element.index == currentIndex), + [playQueue, currentIndex]); + + final PlayQueueItem? currentPlay = useMemoized( + () => playQueue.isEmpty || currentPlayIndex < 0 + ? null + : playQueue[currentPlayIndex], + [playQueue, currentPlayIndex]); + + final file = useMemoized(() => currentPlay?.file, [currentPlay]); + + final externalSubtitle = useState(null); + + final List externalSubtitles = useMemoized( + () => currentPlay?.file.subtitles ?? [], [currentPlay?.file.subtitles]); + + final controller = useMemoized(() { + if (file == null) return VideoPlayerController.networkUrl(Uri.parse('')); + switch (checkDataSourceType(file)) { + case DataSourceType.network: + return VideoPlayerController.networkUrl( + Uri.parse(file.uri), + httpHeaders: file.auth != null ? {'authorization': file.auth!} : {}, + ); + case DataSourceType.file: + return VideoPlayerController.file( + File(file.uri), + httpHeaders: file.auth != null ? {'authorization': file.auth!} : {}, + ); + case DataSourceType.contentUri: + return VideoPlayerController.contentUri( + Uri.parse(file.uri), + ); + default: + return VideoPlayerController.networkUrl( + Uri.parse(file.uri), + httpHeaders: file.auth != null ? {'authorization': file.auth!} : {}, + ); + } + }, [file]); + + useEffect(() { + () async { + if (controller.dataSource.isEmpty) return; + await controller.initialize(); + await controller.setLooping(repeat == Repeat.one ? true : false); + await controller.setVolume(isMuted ? 0 : volume / 100); + }(); + + return () { + controller.dispose(); + externalSubtitle.value = null; + }; + }, [controller]); + + useEffect(() => controller.dispose, []); + + final isPlaying = + useListenableSelector(controller, () => controller.value.isPlaying); + final duration = + useListenableSelector(controller, () => controller.value.duration); + final position = + useListenableSelector(controller, () => controller.value.position); + final buffered = + useListenableSelector(controller, () => controller.value.buffered); + final playbackSpeed = + useListenableSelector(controller, () => controller.value.playbackSpeed); + final size = useListenableSelector(controller, () => controller.value.size); + final isCompleted = + useListenableSelector(controller, () => controller.value.isCompleted); + + final double aspect = useMemoized( + () => size.width != 0 && size.height != 0 ? size.width / size.height : 0, + [size.width, size.height]); + + final seeking = useState(false); + + useEffect(() { + () async { + if (duration != Duration.zero && + currentPlay != null && + currentPlay.file.type == ContentType.video) { + Progress? progress = history[currentPlay.file.getID()]; + if (progress != null) { + if (!alwaysPlayFromBeginning && + (progress.duration.inMilliseconds - + progress.position.inMilliseconds) > + 5000) { + logger( + 'Resume progress: ${currentPlay.file.name} position: ${progress.position} duration: ${progress.duration}'); + await controller.seekTo(progress.position); + } + } + } + + if (autoPlay) { + controller.play(); + } + + if (externalSubtitles.isNotEmpty) { + externalSubtitle.value = 0; + } + }(); + return; + }, [duration]); + + useEffect(() { + if (externalSubtitle.value == null || externalSubtitles.isEmpty) { + controller.setExternalSubtitle(''); + } else if (externalSubtitle.value! < externalSubtitles.length) { + controller + .setExternalSubtitle(externalSubtitles[externalSubtitle.value!].uri); + } + return; + }, [externalSubtitles, externalSubtitle.value]); + + useEffect(() { + () async { + if (currentPlay != null && + isCompleted && + controller.value.position != Duration.zero && + controller.value.duration != Duration.zero) { + logger('Completed: ${currentPlay.file.name}'); + if (repeat == Repeat.one) return; + if (currentPlayIndex == playQueue.length - 1) { + if (repeat == Repeat.all) { + await usePlayQueueStore().updateCurrentIndex(playQueue[0].index); + } + } else { + await usePlayQueueStore() + .updateCurrentIndex(playQueue[currentPlayIndex + 1].index); + } + } + }(); + return; + }, [isCompleted]); + + useEffect(() { + if (controller.value.isInitialized) { + controller.setVolume(isMuted ? 0 : volume / 100); + } + return; + }, [volume, isMuted]); + + useEffect(() { + if (controller.value.isInitialized) { + logger('Set looping: $looping'); + controller.setLooping(repeat == Repeat.one ? true : false); + } + return; + }, [looping]); + + useEffect(() { + return () { + if (currentPlay != null && + controller.value.isInitialized && + controller.value.duration.inSeconds != 0) { + if (Platform.isAndroid && + currentPlay.file.uri.startsWith('content://')) { + return; + } + logger( + 'Save progress: ${currentPlay.file.name}, position: ${controller.value.position}, duration: ${controller.value.duration}'); + useHistoryStore().add(Progress( + dateTime: DateTime.now().toUtc(), + position: controller.value.position, + duration: controller.value.duration, + file: currentPlay.file, + )); + } + }; + }, [currentPlay?.file]); + + useEffect(() { + if (isPlaying) { + logger('Enable wakelock'); + WakelockPlus.enable(); + } else { + logger('Disable wakelock'); + WakelockPlus.disable(); + } + return; + }, [isPlaying]); + + Future play() async { + await useAppStore().updateAutoPlay(true); + controller.play(); + } + + Future pause() async { + await useAppStore().updateAutoPlay(false); + controller.pause(); + } + + Future seekTo(Duration newPosition) async { + logger('Seek to: $newPosition'); + if (duration == Duration.zero) return; + newPosition.inSeconds < 0 + ? await controller.seekTo(Duration.zero) + : newPosition.inSeconds > duration.inSeconds + ? await controller.seekTo(duration) + : await controller.seekTo(newPosition); + } + + Future saveProgress() async { + if (file != null && duration != Duration.zero) { + if (Platform.isAndroid && file.uri.startsWith('content://')) { + return; + } + logger( + 'Save progress: ${file.name}, position: $position, duration: $duration'); + useHistoryStore().add(Progress( + dateTime: DateTime.now().toUtc(), + position: position, + duration: duration, + file: file, + )); + } + } + + useEffect(() => saveProgress, []); + + return FvpPlayer( + controller: controller, + isPlaying: isPlaying, + externalSubtitle: externalSubtitle, + externalSubtitles: externalSubtitles, + position: duration == Duration.zero ? Duration.zero : position, + duration: duration, + buffer: buffered.isEmpty || duration == Duration.zero + ? Duration.zero + : buffered.reduce((max, curr) => curr.end > max.end ? curr : max).end, + aspect: aspect, + width: size.width, + height: size.height, + rate: playbackSpeed, + play: play, + pause: pause, + backward: (seconds) => + seekTo(Duration(seconds: position.inSeconds - seconds)), + forward: (seconds) => + seekTo(Duration(seconds: position.inSeconds + seconds)), + updateRate: (value) => controller.setPlaybackSpeed(value), + seekTo: seekTo, + saveProgress: saveProgress, + seeking: seeking.value, + updatePosition: seekTo, + updateSeeking: (value) => seeking.value = value, + ); +} diff --git a/lib/hooks/use_player_core.dart b/lib/hooks/use_media_kit_player.dart similarity index 62% rename from lib/hooks/use_player_core.dart rename to lib/hooks/use_media_kit_player.dart index 9ae0bfc..7f86f93 100644 --- a/lib/hooks/use_player_core.dart +++ b/lib/hooks/use_media_kit_player.dart @@ -1,75 +1,67 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_zustand/flutter_zustand.dart'; -import 'package:collection/collection.dart'; import 'package:iris/models/file.dart'; +import 'package:iris/models/player.dart'; import 'package:iris/models/progress.dart'; -import 'package:iris/models/storages/storage.dart'; import 'package:iris/models/store/app_state.dart'; import 'package:iris/store/use_app_store.dart'; import 'package:iris/store/use_history_store.dart'; import 'package:iris/store/use_play_queue_store.dart'; -import 'package:iris/store/use_storage_store.dart'; -import 'package:iris/utils/files_filter.dart'; import 'package:iris/utils/logger.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:path_provider/path_provider.dart'; + +MediaKitPlayer useMediaKitPlayer(BuildContext context) { + final player = useMemoized( + () => Player( + configuration: const PlayerConfiguration( + libass: true, + ), + ), + ); -enum MediaType { - video, - audio, -} + final controller = useMemoized(() => VideoController(player)); -class PlayerCore { - final Player player; - final SubtitleTrack subtitle; - final List subtitles; - final List externalSubtitles; - final AudioTrack audio; - final List audios; - final bool playing; - final VideoParams? videoParams; - final AudioParams? audioParams; - final MediaType? mediaType; - final Duration position; - final Duration duration; - final Duration buffer; - final bool seeking; - final bool completed; - final double rate; - final FileItem? cover; - final void Function(Duration) updatePosition; - final void Function(bool) updateSeeking; - final Future Function() saveProgress; - - PlayerCore( - this.player, - this.subtitle, - this.subtitles, - this.externalSubtitles, - this.audio, - this.audios, - this.playing, - this.videoParams, - this.audioParams, - this.mediaType, - this.position, - this.duration, - this.buffer, - this.seeking, - this.completed, - this.rate, - this.cover, - this.updatePosition, - this.updateSeeking, - this.saveProgress, - ); -} + final volume = useAppStore().select(context, (state) => state.volume); + final isMuted = useAppStore().select(context, (state) => state.isMuted); + + useEffect(() { + () async { + player.setSubtitleTrack(SubtitleTrack.no()); + player.setVolume(isMuted ? 0 : volume.toDouble()); + + if (Platform.isAndroid) { + NativePlayer nativePlayer = player.platform as NativePlayer; + + final appSupportDir = await getApplicationSupportDirectory(); + final String fontsDir = "${appSupportDir.path}/fonts"; + + final Directory fontsDirectory = Directory(fontsDir); + if (!await fontsDirectory.exists()) { + await fontsDirectory.create(recursive: true); + logger('fonts directory created'); + } + + final File file = File("$fontsDir/NotoSansCJKsc-Medium.otf"); + if (!await file.exists()) { + final ByteData data = + await rootBundle.load("assets/fonts/NotoSansCJKsc-Medium.otf"); + final Uint8List buffer = data.buffer.asUint8List(); + await file.create(recursive: true); + await file.writeAsBytes(buffer); + logger('NotoSansCJKsc-Medium.otf copied'); + } -PlayerCore usePlayerCore(BuildContext context, Player player) { - final localStorages = - useStorageStore().select(context, (state) => state.localStorages); - final storages = useStorageStore().select(context, (state) => state.storages); + await nativePlayer.setProperty("sub-fonts-dir", fontsDir); + await nativePlayer.setProperty("sub-font", "NotoSansCJKsc-Medium"); + } + }(); + return player.dispose; + }, []); final List playQueue = usePlayQueueStore().select(context, (state) => state.playQueue); @@ -97,7 +89,7 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { bool playing = useStream(player.stream.playing).data ?? false; VideoParams? videoParams = useStream(player.stream.videoParams).data; - AudioParams? audioParams = useStream(player.stream.audioParams).data; + // AudioParams? audioParams = useStream(player.stream.audioParams).data; ValueNotifier position = useState(Duration.zero); Duration duration = useStream(player.stream.duration).data ?? Duration.zero; Duration buffer = useStream(player.stream.buffer).data ?? Duration.zero; @@ -123,10 +115,6 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { (subtitle) => subtitles.any((item) => item.title == subtitle.name)), [currentFile?.subtitles, subtitles]); - final MediaType? mediaType = useMemoized( - () => videoParams?.aspect != null ? MediaType.video : MediaType.audio, - [videoParams?.aspect]); - final positionStream = useStream(player.stream.position); if (positionStream.hasData) { @@ -135,36 +123,6 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { } } - final List dir = useMemoized( - () => currentFile == null || currentFile.path.isEmpty - ? [] - : ([...currentFile.path]..removeLast()), - [currentFile], - ); - - final Storage? storage = useMemoized( - () => currentFile == null - ? null - : [...localStorages, ...storages].firstWhereOrNull( - (storage) => storage.id == currentFile.storageId), - [currentFile, localStorages, storages]); - - final getCover = useMemoized(() async { - if (currentFile?.type != ContentType.audio) return null; - - final files = await storage?.getFiles(dir); - - if (files == null) return null; - - final images = filesFilter(files, [ContentType.image]); - - return images.firstWhereOrNull( - (image) => image.name.split('.').first.toLowerCase() == 'cover') ?? - images.firstOrNull; - }, [currentFile, dir]); - - final cover = useFuture(getCover).data; - useEffect(() { if (currentFile == null || playQueue.isEmpty) { player.stop(); @@ -183,7 +141,8 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { if (Platform.isAndroid && currentFile.uri.startsWith('content://')) { return; } - logger('Save progress: ${currentFile.name}'); + logger( + 'Save progress: ${currentFile.name}, position: ${player.state.position}, duration: ${player.state.duration}'); useHistoryStore().add(Progress( dateTime: DateTime.now().toUtc(), position: player.state.position, @@ -205,7 +164,6 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { Progress? progress = history[currentFile.getID()]; if (progress != null) { if (!alwaysPlayFromBeginning && - progress.duration.inMilliseconds == duration.inMilliseconds && (progress.duration.inMilliseconds - progress.position.inMilliseconds) > 5000) { @@ -252,6 +210,11 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { return null; }, [completed, repeat]); + useEffect(() { + player.setVolume(isMuted ? 0 : volume.toDouble()); + return; + }, [volume, isMuted]); + useEffect(() { logger('$repeat'); if (repeat == Repeat.one) { @@ -271,7 +234,8 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { if (Platform.isAndroid && currentFile.uri.startsWith('content://')) { return; } - logger('Save progress: ${currentFile.name}'); + logger( + 'Save progress: ${currentFile.name}, position: ${player.state.position}, duration: ${player.state.duration}'); useHistoryStore().add(Progress( dateTime: DateTime.now().toUtc(), position: player.state.position, @@ -281,26 +245,60 @@ PlayerCore usePlayerCore(BuildContext context, Player player) { } } - return PlayerCore( - player, - subtitle, - subtitles, - externalSubtitles ?? [], - audio, - audios, - playing, - videoParams, - audioParams, - mediaType, - duration == Duration.zero ? Duration.zero : position.value, - duration, - duration == Duration.zero ? Duration.zero : buffer, - seeking.value, - completed, - rate, - cover, - updatePosition, - updateSeeking, - saveProgress, + useEffect(() => saveProgress, []); + + Future play() async { + await useAppStore().updateAutoPlay(true); + await player.play(); + } + + Future pause() async { + await useAppStore().updateAutoPlay(false); + await player.pause(); + } + + Future seekTo(Duration newPosition) async => newPosition.inSeconds < 0 + ? await player.seek(Duration.zero) + : newPosition.inSeconds > duration.inSeconds + ? await player.seek(duration) + : await player.seek(newPosition); + + Future backward(int seconds) async { + await seekTo(Duration(seconds: position.value.inSeconds - seconds)); + } + + Future forward(int seconds) async { + await seekTo(Duration(seconds: position.value.inSeconds + seconds)); + } + + Future updateRate(double value) async => + player.state.rate == value ? null : await player.setRate(value); + + return MediaKitPlayer( + player: player, + controller: controller, + subtitle: subtitle, + subtitles: subtitles, + externalSubtitles: externalSubtitles ?? [], + audio: audio, + audios: audios, + isPlaying: playing, + position: duration == Duration.zero ? Duration.zero : position.value, + duration: duration, + buffer: duration == Duration.zero ? Duration.zero : buffer, + seeking: seeking.value, + rate: rate, + aspect: videoParams?.aspect, + width: videoParams?.w?.toDouble(), + height: videoParams?.h?.toDouble(), + updatePosition: updatePosition, + updateSeeking: updateSeeking, + saveProgress: saveProgress, + play: play, + pause: pause, + backward: backward, + forward: forward, + updateRate: updateRate, + seekTo: seekTo, ); } diff --git a/lib/hooks/use_player_controller.dart b/lib/hooks/use_player_controller.dart deleted file mode 100644 index 2e68b07..0000000 --- a/lib/hooks/use_player_controller.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_zustand/flutter_zustand.dart'; -import 'package:iris/hooks/use_player_core.dart'; -import 'package:iris/models/file.dart'; -import 'package:iris/store/use_app_store.dart'; -import 'package:iris/store/use_play_queue_store.dart'; -import 'package:iris/utils/get_shuffle_play_queue.dart'; - -class PlayerController { - final Future Function() play; - final Future Function() pause; - final Future Function() previous; - final Future Function() next; - final Future Function(int) backward; - final Future Function(int) forward; - final Future Function(double) updateRate; - final Future Function(Duration) seekTo; - final Future Function() shufflePlayQueue; - final Future Function() sortPlayQueue; - - PlayerController( - this.play, - this.pause, - this.previous, - this.next, - this.backward, - this.forward, - this.updateRate, - this.seekTo, - this.shufflePlayQueue, - this.sortPlayQueue, - ); -} - -PlayerController usePlayerController( - BuildContext context, PlayerCore playerCore) { - final List playQueue = - usePlayQueueStore().select(context, (state) => state.playQueue); - final int currentIndex = - usePlayQueueStore().select(context, (state) => state.currentIndex); - - final int currentPlayIndex = useMemoized( - () => playQueue.indexWhere((element) => element.index == currentIndex), - [playQueue, currentIndex]); - - Future play() async { - await useAppStore().updateAutoPlay(true); - await playerCore.player.play(); - } - - Future pause() async { - // useAppStore().updateAutoPlay(false); - await playerCore.player.pause(); - } - - Future previous() async { - if (currentPlayIndex == 0) return; - await usePlayQueueStore() - .updateCurrentIndex(playQueue[currentPlayIndex - 1].index); - } - - Future next() async { - if (currentPlayIndex == playQueue.length - 1) return; - await usePlayQueueStore() - .updateCurrentIndex(playQueue[currentPlayIndex + 1].index); - } - - Future seekTo(Duration newPosition) async => newPosition.inSeconds < 0 - ? await playerCore.player.seek(Duration.zero) - : newPosition.inSeconds > playerCore.duration.inSeconds - ? await playerCore.player.seek(playerCore.duration) - : await playerCore.player.seek(newPosition); - - Future backward(int seconds) async { - await seekTo(Duration(seconds: playerCore.position.inSeconds - seconds)); - } - - Future forward(int seconds) async { - await seekTo(Duration(seconds: playerCore.position.inSeconds + seconds)); - } - - Future updateRate(double value) async => - playerCore.rate == value ? null : await playerCore.player.setRate(value); - - Future shufflePlayQueue() async => usePlayQueueStore().update( - playQueue: getShufflePlayQueue(playQueue, currentIndex), - index: currentIndex, - ); - - Future sortPlayQueue() async => usePlayQueueStore().update( - playQueue: [...playQueue]..sort((a, b) => a.index.compareTo(b.index)), - index: currentIndex, - ); - - return PlayerController( - play, - pause, - previous, - next, - backward, - forward, - updateRate, - seekTo, - shufflePlayQueue, - sortPlayQueue, - ); -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 28ef8ed..b7ee381 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12,6 +12,7 @@ "always_play_from_beginning": "Always play from the beginning", "always_play_from_beginning_description": "When enabled, this option will automatically start the video from the beginning each time it is played", "app_description": "A lightweight video player", + "audio": "audio", "audio_track": "Audio track", "author": "Author", "auto": "Auto", @@ -33,19 +34,23 @@ "enter_fullscreen": "Enter fullscreen", "exit_app_back_again": "Press back again to exit.", "exit_fullscreen": "Exit fullscreen", + "experimental": "Experimental", "favorites": "Favorites", "fit": "Fit", + "folder_first": "Folder first", "general": "General", "grant_storage_permission": "Grant Storage Permission", "history": "History", "home": "Home", "host": "Host", "language": "Language", + "last_modified": "Last modified", "libraries": "Libraries", "light": "Light", "local_storage": "Local storage", "menu": "Menu", "more_options": "More options", + "mute": "Mute", "name": "Name", "next": "Next", "no_new_version": "No new version", @@ -60,6 +65,7 @@ "pause": "Pause", "play": "Play", "play_queue": "Play queue", + "player_backend": "Player backend", "port": "Port", "previous": "Previous", "refresh": "Refresh", @@ -74,6 +80,8 @@ "select_language": "Select language", "settings": "Settings", "shuffle": "Shuffle", + "size": "Size", + "sort": "Sort", "source_code": "Source code", "storage": "Storage", "stretch": "Stretch", @@ -83,9 +91,12 @@ "test_connection": "Test connection", "theme_mode": "Theme mode", "unable_to_fetch_files": "Unable to fetch files.", + "unmute": "Unmute", "url": "URL", "usb_storage": "USB storage", "username": "Username", "version": "Version", - "video_zoom": "Video zoom" + "video": "Video", + "video_zoom": "Video zoom", + "volume": "Volume" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 58b0654..5022a90 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -12,6 +12,7 @@ "always_play_from_beginning": "总是从头开始播放", "always_play_from_beginning_description": "启用此选项后,每次播放视频时将自动从头开始播放", "app_description": "轻量级视频播放器", + "audio": "音频", "audio_track": "音轨", "author": "作者", "auto": "自动", @@ -33,19 +34,23 @@ "enter_fullscreen": "进入全屏", "exit_app_back_again": "再次点击返回退出应用。", "exit_fullscreen": "退出全屏", + "experimental": "实验性", "favorites": "收藏", "fit": "适应", + "folder_first": "文件夹优先", "general": "通用", "grant_storage_permission": "授予存储权限", "history": "历史", "home": "主页", "host": "主机", "language": "语言", + "last_modified": "最后修改", "libraries": "开源库", "light": "亮色", "local_storage": "本地存储", "menu": "菜单", "more_options": "更多选项", + "mute": "静音", "name": "名称", "next": "下一个", "no_new_version": "没有新版本", @@ -60,6 +65,7 @@ "pause": "暂停", "play": "播放", "play_queue": "播放队列", + "player_backend": "播放器后端", "port": "端口", "previous": "上一个", "refresh": "刷新", @@ -74,6 +80,8 @@ "select_language": "选择语言", "settings": "设置", "shuffle": "随机", + "size": "大小", + "sort": "排序", "source_code": "源码", "storage": "存储", "stretch": "拉伸", @@ -83,9 +91,12 @@ "test_connection": "测试连接", "theme_mode": "主题模式", "unable_to_fetch_files": "无法获取文件", + "unmute": "取消静音", "url": "URL", "usb_storage": "USB 存储", "username": "用户名", "version": "版本", - "video_zoom": "视频缩放" + "video": "视频", + "video_zoom": "视频缩放", + "volume": "音量" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 68af999..de4c891 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:app_links/app_links.dart'; +import 'package:fvp/fvp.dart' as fvp; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -26,8 +27,22 @@ void main(List arguments) async { globals.arguments = arguments; WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); + fvp.registerWith(options: { + // 'fastSeek': true, + 'player': { + if (Platform.isAndroid) 'audio.renderer': 'AudioTrack', + 'avio.reconnect': '1', + 'avio.reconnect_delay_max': '7', + 'buffer': '2000+80000', + 'demux.buffer.ranges': '8', + }, + if (Platform.isAndroid) + 'subtitleFontFile': 'assets/fonts/NotoSansCJKsc-Medium.otf', + }); + final appLinks = AppLinks(); final initUri = await appLinks.getInitialLinkString(); diff --git a/lib/models/file.dart b/lib/models/file.dart index 2d560a4..cc7ccaf 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -30,6 +30,7 @@ abstract class FileItem implements _$FileItem { @Default([]) List path, @Default(false) bool isDir, @Default(0) int size, + DateTime? lastModified, @Default(ContentType.video) ContentType type, String? auth, @Default([]) List subtitles, diff --git a/lib/models/player.dart b/lib/models/player.dart new file mode 100644 index 0000000..6047721 --- /dev/null +++ b/lib/models/player.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:iris/models/file.dart'; +import 'package:media_kit/media_kit.dart' as media_kit; +import 'package:media_kit_video/media_kit_video.dart' as media_kit_video; +import 'package:video_player/video_player.dart'; + +class MediaPlayer { + final bool isPlaying; + final List externalSubtitles; + final Duration position; + final Duration duration; + final Duration buffer; + final bool seeking; + final double rate; + final double? aspect; + final double? width; + final double? height; + final void Function(Duration) updatePosition; + final void Function(bool) updateSeeking; + final Future Function() saveProgress; + final Future Function() play; + final Future Function() pause; + final Future Function(int) backward; + final Future Function(int) forward; + final Future Function(double) updateRate; + final Future Function(Duration) seekTo; + + MediaPlayer({ + required this.isPlaying, + required this.externalSubtitles, + required this.position, + required this.duration, + required this.buffer, + required this.seeking, + required this.rate, + required this.aspect, + required this.width, + required this.height, + required this.updatePosition, + required this.updateSeeking, + required this.saveProgress, + required this.play, + required this.pause, + required this.backward, + required this.forward, + required this.updateRate, + required this.seekTo, + }); +} + +class MediaKitPlayer extends MediaPlayer { + final media_kit.Player player; + final media_kit_video.VideoController controller; + final media_kit.SubtitleTrack subtitle; + final List subtitles; + final media_kit.AudioTrack audio; + final List audios; + + MediaKitPlayer({ + required this.player, + required this.controller, + required this.subtitle, + required this.subtitles, + required super.externalSubtitles, + required this.audio, + required this.audios, + required super.isPlaying, + required super.position, + required super.duration, + required super.buffer, + required super.seeking, + required super.rate, + required super.aspect, + required super.width, + required super.height, + required super.updatePosition, + required super.updateSeeking, + required super.saveProgress, + required super.play, + required super.pause, + required super.backward, + required super.forward, + required super.updateRate, + required super.seekTo, + }); +} + +class FvpPlayer extends MediaPlayer { + final VideoPlayerController controller; + final ValueNotifier externalSubtitle; + + FvpPlayer({ + required this.controller, + required super.isPlaying, + required this.externalSubtitle, + required super.externalSubtitles, + required super.position, + required super.duration, + required super.buffer, + required super.seeking, + required super.rate, + required super.aspect, + required super.width, + required super.height, + required super.updatePosition, + required super.updateSeeking, + required super.saveProgress, + required super.play, + required super.pause, + required super.backward, + required super.forward, + required super.updateRate, + required super.seekTo, + }); +} diff --git a/lib/models/storages/local.dart b/lib/models/storages/local.dart index 9d11ce9..fd2703b 100644 --- a/lib/models/storages/local.dart +++ b/lib/models/storages/local.dart @@ -31,18 +31,30 @@ Future> getLocalFiles( await for (final entity in entities) { final isDir = entity is Directory; int size = 0; + DateTime? lastModified; if (!isDir) { final file = File(entity.path); try { size = await file.length(); + lastModified = await file.lastModified(); } on PathAccessException catch (e) { logger( - 'PathAccessException when getting file size for ${entity.path}: $e, setting size to 0'); - size = 0; + 'PathAccessException when getting file info for ${entity.path}: $e'); } catch (e) { + logger('Error getting file info for ${entity.path}: $e'); + } + } + + if (isDir) { + final dir = Directory(entity.path); + try { + final stat = await dir.stat(); + lastModified = stat.modified; + } on PathAccessException catch (e) { logger( - 'Error getting file size for ${entity.path}: $e, setting size to 0'); - size = 0; + 'PathAccessException when getting directory info for ${entity.path}: $e'); + } catch (e) { + logger('Error getting directory info for ${entity.path}: $e'); } } @@ -60,6 +72,7 @@ Future> getLocalFiles( path: [...path, p.basename(entity.path)], isDir: isDir, size: size, + lastModified: lastModified, type: isDir ? ContentType.dir : checkContentType(p.basename(entity.path)), @@ -70,7 +83,7 @@ Future> getLocalFiles( return []; } - return filesSort(files, true); + return files; } Future> getLocalStorages( @@ -143,8 +156,9 @@ Future getLocalPlayQueue(List filePath) async { name: filePath.last, basePath: dirPath, ).getFiles(dirPath); + final List sortedFiles = filesSort(files: files); final List filteredFiles = - filesFilter(files, [ContentType.video, ContentType.audio]); + filesFilter(sortedFiles, [ContentType.video, ContentType.audio]); final List playQueue = filteredFiles .asMap() .entries diff --git a/lib/models/storages/webdav.dart b/lib/models/storages/webdav.dart index 024e425..3b3a956 100644 --- a/lib/models/storages/webdav.dart +++ b/lib/models/storages/webdav.dart @@ -73,6 +73,7 @@ Future> getWebDAVFiles( path: [...path, '${file.name}'], isDir: file.isDir ?? false, size: file.size ?? 0, + lastModified: file.mTime, type: file.isDir ?? false ? ContentType.dir : checkContentType(file.name!), diff --git a/lib/models/store/app_state.dart b/lib/models/store/app_state.dart index 377e661..47bf5ac 100644 --- a/lib/models/store/app_state.dart +++ b/lib/models/store/app_state.dart @@ -4,12 +4,28 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'app_state.freezed.dart'; part 'app_state.g.dart'; +enum PlayerBackend { + mediaKit, + fvp, +} + enum Repeat { none, all, one, } +enum SortBy { + name, + size, + lastModified, +} + +enum SortOrder { + asc, + desc, +} + @freezed class AppState with _$AppState { const factory AppState({ @@ -17,7 +33,7 @@ class AppState with _$AppState { @Default(false) bool shuffle, @Default(Repeat.none) Repeat repeat, @Default(BoxFit.contain) BoxFit fit, - @Default(100) int volume, + @Default(80) int volume, @Default(false) bool isMuted, @Default(ThemeMode.system) ThemeMode themeMode, @Default('none') String preferedSubtitleLanguage, @@ -25,6 +41,10 @@ class AppState with _$AppState { @Default(false) bool autoCheckUpdate, @Default(false) bool autoResize, @Default(false) bool alwaysPlayFromBeginning, + @Default(PlayerBackend.mediaKit) PlayerBackend playerBackend, + @Default(SortBy.name) SortBy sortBy, + @Default(SortOrder.asc) SortOrder sortOrder, + @Default(true) bool folderFirst, }) = _AppState; factory AppState.fromJson(Map json) => diff --git a/lib/models/store/ui_state.dart b/lib/models/store/ui_state.dart new file mode 100644 index 0000000..8d19392 --- /dev/null +++ b/lib/models/store/ui_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:flutter/foundation.dart'; + +part 'ui_state.freezed.dart'; +part 'ui_state.g.dart'; + +@freezed +class UiState with _$UiState { + const factory UiState({ + @Default(false) bool isAlwaysOnTop, + }) = _UiState; + + factory UiState.fromJson(Map json) => + _$UiStateFromJson(json); +} diff --git a/lib/oss_licenses.dart b/lib/oss_licenses.dart index 2673247..2fbddf3 100644 --- a/lib/oss_licenses.dart +++ b/lib/oss_licenses.dart @@ -35,6 +35,7 @@ const allDependencies = [ _convert, _cross_file, _crypto, + _csslib, _cupertino_icons, _dart_pubspec_licenses, _dart_style, @@ -70,10 +71,12 @@ const allDependencies = [ _freezed, _freezed_annotation, _frontend_server_client, + _fvp, _glob, _google_fonts, _graphs, _gtk, + _html, _http, _http_multi_server, _http_parser, @@ -125,6 +128,7 @@ const allDependencies = [ _platform, _plugin_platform_interface, _pool, + _popover, _posix, _provider, _pub_semver, @@ -170,6 +174,11 @@ const allDependencies = [ _url_launcher_windows, _uuid, _vector_math, + _video_player, + _video_player_android, + _video_player_avfoundation, + _video_player_platform_interface, + _video_player_web, _vm_service, _volume_controller, _wakelock_plus, @@ -208,6 +217,7 @@ const dependencies = [ _flutter_volume_controller, _flutter_zustand, _freezed_annotation, + _fvp, _google_fonts, _http, _intl, @@ -219,12 +229,15 @@ const dependencies = [ _path, _path_provider, _permission_handler, + _popover, _provider, _saf_util, _screen_brightness, _scrollable_positioned_list, _url_launcher, _uuid, + _video_player, + _wakelock_plus, _webdav_client, _window_manager, _window_size @@ -2206,6 +2219,45 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', dependencies: [PackageRef('typed_data')] ); +/// csslib 1.0.2 +const _csslib = Package( + name: 'csslib', + description: 'A library for parsing and analyzing CSS (Cascading Style Sheets).', + repository: 'https://github.com/dart-lang/tools/tree/main/pkgs/csslib', + authors: [], + version: '1.0.2', + license: '''Copyright 2013, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('source_span')] + ); + /// cupertino_icons 1.0.8 const _cupertino_icons = Package( name: 'cupertino_icons', @@ -3708,13 +3760,13 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', dependencies: [] ); -/// flutter 3.27.1 +/// flutter 3.27.3 const _flutter = Package( name: 'flutter', description: 'A framework for writing Flutter applications', homepage: 'https://flutter.dev', authors: [], - version: '3.27.1', + version: '3.27.3', license: '''Copyright 2014 The Flutter Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, @@ -4386,6 +4438,45 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', dependencies: [PackageRef('async'), PackageRef('path')] ); +/// fvp 0.29.0 +const _fvp = Package( + name: 'fvp', + description: 'video_player plugin and backend APIs. Support all desktop/mobile platforms with hardware decoders, optimal renders. Supports most formats via FFmpeg', + homepage: 'https://github.com/wang-bin/fvp', + authors: [], + version: '0.29.0', + license: '''BSD-3-Clause License + +Copyright 2022 Wang Bin. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('ffi'), PackageRef('flutter'), PackageRef('logging'), PackageRef('path'), PackageRef('plugin_platform_interface'), PackageRef('video_player'), PackageRef('video_player_platform_interface')] + ); + /// glob 2.1.2 const _glob = Package( name: 'glob', @@ -5063,6 +5154,41 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice dependencies: [PackageRef('ffi'), PackageRef('flutter'), PackageRef('meta')] ); +/// html 0.15.5 +const _html = Package( + name: 'html', + description: 'APIs for parsing and manipulating HTML content outside the browser.', + repository: 'https://github.com/dart-lang/tools/tree/main/pkgs/html', + authors: [], + version: '0.15.5', + license: '''Copyright (c) 2006-2012 The Authors + +Contributors: +James Graham - jg307@cam.ac.uk +Anne van Kesteren - annevankesteren@gmail.com +Lachlan Hunt - lachlan.hunt@lachy.id.au +Matt McDonald - kanashii@kanashii.ca +Sam Ruby - rubys@intertwingly.net +Ian Hickson (Google) - ian@hixie.ch +Thomas Broyer - t.broyer@ltgt.net +Jacques Distler - distler@golem.ph.utexas.edu +Henri Sivonen - hsivonen@iki.fi +Adam Barth - abarth@webkit.org +Eric Seidel - eric@webkit.org +The Mozilla Foundation (contributions from Henri Sivonen since 2008) +David Flanagan (Mozilla) - dflanagan@mozilla.com +Google LLC (contributed the Dart port) - misc@dartlang.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('csslib'), PackageRef('source_span')] + ); + /// http 1.2.2 const _http = Package( name: 'http', @@ -7115,6 +7241,39 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', dependencies: [PackageRef('async'), PackageRef('stack_trace')] ); +/// popover 0.3.1 +const _popover = Package( + name: 'popover', + description: 'A popover is a transient view that appears above other content onscreen when you tap a control or in an area.', + homepage: 'https://github.com/minikin/popover', + authors: [], + version: '0.3.1', + license: '''MIT License + +Copyright (c) 2021 - 2024 Oleksandr Prokhorenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('flutter')] + ); + /// posix 6.0.1 const _posix = Package( name: 'posix', @@ -9126,6 +9285,191 @@ freely, subject to the following restrictions: dependencies: [] ); +/// video_player 2.9.2 +const _video_player = Package( + name: 'video_player', + description: 'Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web.', + repository: 'https://github.com/flutter/packages/tree/main/packages/video_player/video_player', + authors: [], + version: '2.9.2', + license: '''Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('flutter'), PackageRef('html'), PackageRef('video_player_android'), PackageRef('video_player_avfoundation'), PackageRef('video_player_platform_interface'), PackageRef('video_player_web')] + ); + +/// video_player_android 2.7.17 +const _video_player_android = Package( + name: 'video_player_android', + description: 'Android implementation of the video_player plugin.', + repository: 'https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android', + authors: [], + version: '2.7.17', + license: '''Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('flutter'), PackageRef('video_player_platform_interface')] + ); + +/// video_player_avfoundation 2.6.7 +const _video_player_avfoundation = Package( + name: 'video_player_avfoundation', + description: 'iOS and macOS implementation of the video_player plugin.', + repository: 'https://github.com/flutter/packages/tree/main/packages/video_player/video_player_avfoundation', + authors: [], + version: '2.6.7', + license: '''Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('flutter'), PackageRef('video_player_platform_interface')] + ); + +/// video_player_platform_interface 6.2.3 +const _video_player_platform_interface = Package( + name: 'video_player_platform_interface', + description: 'A common platform interface for the video_player plugin.', + repository: 'https://github.com/flutter/packages/tree/main/packages/video_player/video_player_platform_interface', + authors: [], + version: '6.2.3', + license: '''Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('flutter'), PackageRef('plugin_platform_interface')] + ); + +/// video_player_web 2.3.3 +const _video_player_web = Package( + name: 'video_player_web', + description: 'Web platform implementation of video_player.', + repository: 'https://github.com/flutter/packages/tree/main/packages/video_player/video_player_web', + authors: [], + version: '2.3.3', + license: '''Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''', + isMarkdown: false, + isSdk: false, + dependencies: [PackageRef('flutter'), PackageRef('video_player_platform_interface'), PackageRef('web')] + ); + /// vm_service 14.3.0 const _vm_service = Package( name: 'vm_service', diff --git a/lib/pages/audio_track_list.dart b/lib/pages/audio_track_list.dart new file mode 100644 index 0000000..3b190b5 --- /dev/null +++ b/lib/pages/audio_track_list.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fvp/fvp.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/utils/get_localizations.dart'; +import 'package:iris/utils/logger.dart'; +import 'package:media_kit/media_kit.dart'; + +class AudioTrackList extends HookWidget { + const AudioTrackList({super.key, required this.player}); + + final MediaPlayer player; + + @override + Widget build(BuildContext context) { + final t = getLocalizations(context); + + final focusNode = useFocusNode(); + + useEffect(() { + focusNode.requestFocus(); + return () => focusNode.unfocus(); + }, []); + + if (player is MediaKitPlayer) { + return ListView( + children: [ + ...(player as MediaKitPlayer).audios.map( + (audio) => ListTile( + focusNode: (player as MediaKitPlayer).audio == audio + ? focusNode + : null, + title: Text( + audio == AudioTrack.auto() + ? t.auto + : audio == AudioTrack.no() + ? t.off + : audio.title ?? audio.language ?? audio.id, + style: (player as MediaKitPlayer).audio == audio + ? TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ) + : TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger( + 'Set audio track: ${audio.title ?? audio.language ?? audio.id}'); + (player as MediaKitPlayer).player.setAudioTrack(audio); + Navigator.of(context).pop(); + }, + ), + ), + ], + ); + } + + if (player is FvpPlayer) { + final audios = + (player as FvpPlayer).controller.getMediaInfo()?.audio ?? []; + final activeAudioTracks = + (player as FvpPlayer).controller.getActiveAudioTracks() ?? []; + return ListView( + children: [ + ListTile( + focusNode: activeAudioTracks.isEmpty ? focusNode : null, + title: Text( + t.off, + style: activeAudioTracks.isEmpty + ? TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ) + : TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger('Set audio track: ${t.off}'); + (player as FvpPlayer).controller.setAudioTracks([]); + Navigator.of(context).pop(); + }, + ), + ...audios.map( + (audio) => ListTile( + focusNode: activeAudioTracks.contains(audios.indexOf(audio)) + ? focusNode + : null, + title: Text( + audio.metadata['title'] ?? + audio.metadata['language'] ?? + audios.indexOf(audio).toString(), + style: activeAudioTracks.contains(audios.indexOf(audio)) + ? TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ) + : TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger( + 'Set audio track: ${audio.metadata['title'] ?? audio.metadata['language'] ?? audios.indexOf(audio).toString()}'); + (player as FvpPlayer) + .controller + .setAudioTracks([audios.indexOf(audio)]); + Navigator.of(context).pop(); + }, + ), + ), + ], + ); + } + + return Container(); + } +} diff --git a/lib/pages/audio_tracks.dart b/lib/pages/audio_tracks.dart deleted file mode 100644 index 59f49ec..0000000 --- a/lib/pages/audio_tracks.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:iris/hooks/use_player_core.dart'; -import 'package:iris/utils/get_localizations.dart'; -import 'package:iris/utils/logger.dart'; -import 'package:media_kit/media_kit.dart'; - -class AudioTracks extends HookWidget { - const AudioTracks({super.key, required this.playerCore}); - - final PlayerCore playerCore; - - @override - Widget build(BuildContext context) { - final t = getLocalizations(context); - - final focusNode = useFocusNode(); - - useEffect(() { - focusNode.requestFocus(); - return () => focusNode.unfocus(); - }, []); - - return ListView( - children: [ - ...playerCore.audios.map( - (audio) => ListTile( - focusNode: playerCore.audio == audio ? focusNode : null, - title: Text( - audio == AudioTrack.auto() - ? t.auto - : audio == AudioTrack.no() - ? t.off - : audio.title ?? audio.language ?? audio.id, - style: playerCore.audio == audio - ? TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ) - : TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - onTap: () { - logger( - 'Set audio track: ${audio.title ?? audio.language ?? audio.id}'); - playerCore.player.setAudioTrack(audio); - Navigator.of(context).pop(); - }, - ), - ), - ], - ); - } -} diff --git a/lib/pages/history.dart b/lib/pages/history.dart index 75bd72e..8f948af 100644 --- a/lib/pages/history.dart +++ b/lib/pages/history.dart @@ -4,6 +4,7 @@ import 'package:flutter_zustand/flutter_zustand.dart'; import 'package:iris/models/file.dart'; import 'package:iris/models/progress.dart'; import 'package:iris/models/storages/storage.dart'; +import 'package:iris/store/use_app_store.dart'; import 'package:iris/store/use_history_store.dart'; import 'package:iris/store/use_play_queue_store.dart'; import 'package:iris/utils/file_size_convert.dart'; @@ -27,6 +28,8 @@ class History extends HookWidget { }, [history]); Future play(int index) async { + await useAppStore().updateAutoPlay(true); + final playQueue = historyList .asMap() .map((index, entry) => MapEntry( @@ -103,7 +106,7 @@ class History extends HookWidget { (subtitleType) => Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 8), + const SizedBox(width: 4), CustomChip( text: subtitleType, primary: true, diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index de9d92e..2a371e8 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,32 +1,35 @@ -import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/hooks/use_fvp_player.dart'; +import 'package:iris/hooks/use_media_kit_player.dart'; +import 'package:iris/models/store/app_state.dart'; import 'package:iris/pages/player/iris_player.dart'; -import 'package:iris/theme.dart'; +import 'package:iris/store/use_app_store.dart'; class HomePage extends HookWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { - return DynamicColorBuilder(builder: ( - ColorScheme? lightDynamic, - ColorScheme? darkDynamic, - ) { - final theme = getTheme( - context: context, - lightDynamic: lightDynamic, - darkDynamic: darkDynamic, - ); - return Scaffold( - body: Theme( - data: theme.dark.copyWith( - colorScheme: theme.dark.colorScheme.copyWith( - onSurfaceVariant: Colors.white.withValues(alpha: 0.95), - )), - child: const IrisPlayer(), - ), - ); - }); + final playerBackend = + useAppStore().select(context, (state) => state.playerBackend); + + final player = () { + switch (playerBackend) { + case PlayerBackend.mediaKit: + return IrisPlayer( + key: const ValueKey('media-kit'), + playerHooks: useMediaKitPlayer, + ); + case PlayerBackend.fvp: + return IrisPlayer( + key: const ValueKey('fvp'), + playerHooks: useFvpPlayer, + ); + } + }(); + + return Scaffold(body: player); } } diff --git a/lib/pages/play_queue.dart b/lib/pages/play_queue.dart index 765ed71..f81bb1c 100644 --- a/lib/pages/play_queue.dart +++ b/lib/pages/play_queue.dart @@ -122,7 +122,7 @@ class PlayQueue extends HookWidget { (subtitleType) => Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 8), + const SizedBox(width: 4), CustomChip( text: subtitleType, primary: true, @@ -159,11 +159,13 @@ class PlayQueue extends HookWidget { ), ], ), - onTap: () { - useAppStore().updateAutoPlay(true); + onTap: () async { + await useAppStore().updateAutoPlay(true); usePlayQueueStore() .updateCurrentIndex(playQueue[index].index); - Navigator.of(context).pop(); + if (context.mounted) { + Navigator.of(context).pop(); + } }, ), itemScrollController: itemScrollController, diff --git a/lib/pages/player/audio.dart b/lib/pages/player/audio.dart index c9a6119..7017270 100644 --- a/lib/pages/player/audio.dart +++ b/lib/pages/player/audio.dart @@ -1,17 +1,16 @@ import 'dart:io'; import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:iris/hooks/use_player_core.dart'; +import 'package:iris/models/file.dart'; class Audio extends HookWidget { const Audio({ super.key, - required this.playerCore, + required this.cover, }); - final PlayerCore playerCore; + final FileItem? cover; @override Widget build(BuildContext context) { @@ -22,16 +21,16 @@ class Audio extends HookWidget { color: Colors.grey[800], width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, - child: playerCore.cover != null - ? playerCore.cover?.storageId == 'local' + child: cover != null + ? cover?.storageId == 'local' ? Image.file( - File(playerCore.cover!.uri), + File(cover!.uri), fit: BoxFit.cover, ) : Image.network( - playerCore.cover!.uri, - headers: playerCore.cover!.auth != null - ? {'authorization': playerCore.cover!.auth!} + cover!.uri, + headers: cover!.auth != null + ? {'authorization': cover!.auth!} : null, fit: BoxFit.cover, ) @@ -53,16 +52,16 @@ class Audio extends HookWidget { height: MediaQuery.of(context).size.height / 2, child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: playerCore.cover != null - ? playerCore.cover!.storageId == 'local' + child: cover != null + ? cover!.storageId == 'local' ? Image.file( - File(playerCore.cover!.uri), + File(cover!.uri), fit: BoxFit.contain, ) : Image.network( - playerCore.cover!.uri, - headers: playerCore.cover!.auth != null - ? {'authorization': playerCore.cover!.auth!} + cover!.uri, + headers: cover!.auth != null + ? {'authorization': cover!.auth!} : null, fit: BoxFit.contain, ) diff --git a/lib/pages/player/control_bar.dart b/lib/pages/player/control_bar.dart index 131cba6..042d6cc 100644 --- a/lib/pages/player/control_bar.dart +++ b/lib/pages/player/control_bar.dart @@ -1,24 +1,24 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_zustand/flutter_zustand.dart'; -import 'package:iris/hooks/use_player_controller.dart'; -import 'package:iris/hooks/use_player_core.dart'; +import 'package:iris/models/player.dart'; import 'package:iris/models/storages/local.dart'; import 'package:iris/models/store/app_state.dart'; import 'package:iris/pages/dialog/show_open_link_dialog.dart'; import 'package:iris/pages/player/control_bar_slider.dart'; import 'package:iris/pages/history.dart'; import 'package:iris/pages/show_open_link_bottom_sheet.dart'; -import 'package:iris/pages/subtitle_and_audio_track.dart'; import 'package:iris/pages/settings/settings.dart'; +import 'package:iris/pages/player/volume_control.dart'; +import 'package:iris/pages/subtitle_and_audio_track.dart'; import 'package:iris/store/use_app_store.dart'; import 'package:iris/store/use_play_queue_store.dart'; import 'package:iris/utils/get_localizations.dart'; import 'package:iris/pages/play_queue.dart'; import 'package:iris/utils/is_desktop.dart'; import 'package:iris/utils/resize_window.dart'; +import 'package:iris/widgets/dark_theme.dart'; import 'package:iris/widgets/popup.dart'; import 'package:iris/pages/storage/storages.dart'; import 'package:window_manager/window_manager.dart'; @@ -26,12 +26,12 @@ import 'package:window_manager/window_manager.dart'; class ControlBar extends HookWidget { const ControlBar({ super.key, - required this.playerCore, + required this.player, required this.showControl, required this.showControlForHover, }); - final PlayerCore playerCore; + final MediaPlayer player; final void Function() showControl; final Future Function(Future callback) showControlForHover; @@ -39,9 +39,8 @@ class ControlBar extends HookWidget { Widget build(BuildContext context) { final t = getLocalizations(context); - final PlayerController playerController = - usePlayerController(context, playerCore); - + final volume = useAppStore().select(context, (state) => state.volume); + final isMuted = useAppStore().select(context, (state) => state.isMuted); final int playQueueLength = usePlayQueueStore().select(context, (state) => state.playQueue.length); final playQueue = @@ -65,9 +64,9 @@ class ControlBar extends HookWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surface.withValues(alpha: 0), - Theme.of(context).colorScheme.surface.withValues(alpha: 0.3), - Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + Colors.black87.withValues(alpha: 0), + Colors.black87.withValues(alpha: 0.3), + Colors.black87.withValues(alpha: 0.8), ], ), ), @@ -75,10 +74,12 @@ class ControlBar extends HookWidget { child: Column( children: [ Visibility( - visible: MediaQuery.of(context).size.width < 960 || !isDesktop, - child: ControlBarSlider( - playerCore: playerCore, - showControl: showControl, + visible: MediaQuery.of(context).size.width < 1024 || !isDesktop, + child: DarkTheme( + child: ControlBarSlider( + player: player, + showControl: showControl, + ), ), ), Row( @@ -86,443 +87,500 @@ class ControlBar extends HookWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 8), - Visibility( - visible: playQueueLength > 1, - child: IconButton( - tooltip: '${t.previous} ( Ctrl + ← )', - icon: Icon( - Icons.skip_previous_rounded, - size: 28, - ), - onPressed: currentPlayIndex == 0 - ? null - : () { - showControl(); - playerController.previous(); - }, - ), - ), - IconButton( - tooltip: - '${playerCore.playing == true ? t.pause : t.play} ( Space )', - icon: Icon( - playerCore.playing == true - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - size: 36, - ), - onPressed: () { - showControl(); - if (playerCore.playing == true) { - playerController.pause(); - } else { - playerController.play(); - } - }, - ), - Visibility( - visible: playQueueLength > 1, - child: IconButton( - tooltip: '${t.next} ( Ctrl + → )', - icon: Icon( - Icons.skip_next_rounded, - size: 28, + if (playQueueLength > 1) + DarkTheme( + child: IconButton( + tooltip: '${t.previous} ( Ctrl + ← )', + icon: Icon( + Icons.skip_previous_rounded, + size: 28, + ), + onPressed: currentPlayIndex == 0 + ? null + : () { + showControl(); + usePlayQueueStore().previous(); + }, ), - onPressed: currentPlayIndex == playQueueLength - 1 - ? null - : () { - showControl(); - playerController.next(); - }, ), - ), - Visibility( - visible: MediaQuery.of(context).size.width >= 600, + DarkTheme( child: IconButton( tooltip: - '${t.shuffle}: ${shuffle ? t.on : t.off} ( Ctrl + X )', + '${player.isPlaying == true ? t.pause : t.play} ( Space )', icon: Icon( - Icons.shuffle_rounded, - size: 20, - color: !shuffle - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.onSurfaceVariant, + player.isPlaying == true + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + size: 36, ), onPressed: () { showControl(); - shuffle - ? playerController.sortPlayQueue() - : playerController.shufflePlayQueue(); - useAppStore().updateShuffle(!shuffle); + if (player.isPlaying == true) { + player.pause(); + } else { + player.play(); + } }, ), ), - Visibility( - visible: MediaQuery.of(context).size.width >= 600, - child: IconButton( - tooltip: - '${repeat == Repeat.one ? t.repeat_one : repeat == Repeat.all ? t.repeat_all : t.repeat_none} ( Ctrl + R )', - icon: Icon( - repeat == Repeat.one - ? Icons.repeat_one_rounded - : Icons.repeat_rounded, - size: 20, - color: repeat == Repeat.none - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.onSurfaceVariant, + if (playQueueLength > 1) + DarkTheme( + child: IconButton( + tooltip: '${t.next} ( Ctrl + → )', + icon: Icon( + Icons.skip_next_rounded, + size: 28, + ), + onPressed: currentPlayIndex == playQueueLength - 1 + ? null + : () { + showControl(); + usePlayQueueStore().next(); + }, ), - onPressed: () { - showControl(); - useAppStore().toggleRepeat(); - }, ), - ), - Visibility( - visible: MediaQuery.of(context).size.width >= 600, - child: IconButton( - tooltip: - '${t.video_zoom}: ${fit == BoxFit.contain ? t.fit : fit == BoxFit.fill ? t.stretch : fit == BoxFit.cover ? t.crop : '100%'} ( Ctrl + V )', - icon: Icon( - fit == BoxFit.contain - ? Icons.fit_screen_rounded - : fit == BoxFit.fill - ? Icons.aspect_ratio_rounded - : fit == BoxFit.cover - ? Icons.crop_landscape_rounded - : Icons.crop_free_rounded, - size: 20, + if (MediaQuery.of(context).size.width >= 768) + DarkTheme( + child: Builder( + builder: (context) => IconButton( + tooltip: + '${t.shuffle}: ${shuffle ? t.on : t.off} ( Ctrl + X )', + icon: Icon( + Icons.shuffle_rounded, + size: 20, + color: !shuffle + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + onPressed: () { + showControl(); + shuffle + ? usePlayQueueStore().sort() + : usePlayQueueStore().shuffle(); + useAppStore().updateShuffle(!shuffle); + }, + ), + ), + ), + if (MediaQuery.of(context).size.width >= 768) + DarkTheme( + child: Builder( + builder: (context) => IconButton( + tooltip: + '${repeat == Repeat.one ? t.repeat_one : repeat == Repeat.all ? t.repeat_all : t.repeat_none} ( Ctrl + R )', + icon: Icon( + repeat == Repeat.one + ? Icons.repeat_one_rounded + : Icons.repeat_rounded, + size: 20, + color: repeat == Repeat.none + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + onPressed: () { + showControl(); + useAppStore().toggleRepeat(); + }, + ), + ), + ), + if (MediaQuery.of(context).size.width >= 768) + DarkTheme( + child: IconButton( + tooltip: + '${t.video_zoom}: ${fit == BoxFit.contain ? t.fit : fit == BoxFit.fill ? t.stretch : fit == BoxFit.cover ? t.crop : '100%'} ( Ctrl + V )', + icon: Icon( + fit == BoxFit.contain + ? Icons.fit_screen_rounded + : fit == BoxFit.fill + ? Icons.aspect_ratio_rounded + : fit == BoxFit.cover + ? Icons.crop_landscape_rounded + : Icons.crop_free_rounded, + size: 20, + ), + onPressed: () { + showControl(); + useAppStore().toggleFit(); + }, + ), + ), + if (MediaQuery.of(context).size.width < 600) + Builder( + builder: (context) => DarkTheme( + child: IconButton( + tooltip: '${t.volume}: $volume', + icon: Icon( + isMuted || volume == 0 + ? Icons.volume_off_rounded + : volume < 50 + ? Icons.volume_down_rounded + : Icons.volume_up_rounded, + size: 20, + ), + onPressed: () => showControlForHover( + showVolumePopover(context, showControl), + ), + ), + ), + ), + if (MediaQuery.of(context).size.width >= 600) + DarkTheme( + child: SizedBox( + width: 160, + child: VolumeControl( + showControl: showControl, + showVolumeText: false, + ), ), - onPressed: () { - showControl(); - useAppStore().toggleFit(); - }, ), - ), Expanded( child: Visibility( visible: - MediaQuery.of(context).size.width >= 960 && isDesktop, - child: ControlBarSlider( - playerCore: playerCore, - showControl: showControl, + MediaQuery.of(context).size.width >= 1024 && isDesktop, + child: DarkTheme( + child: ControlBarSlider( + player: player, + showControl: showControl, + ), ), ), ), - // Visibility( - // visible: MediaQuery.of(context).size.width > 600, - // child: IconButton( - // tooltip: t.open_link, - // icon: const Icon(Icons.file_present_rounded), - // onPressed: () async { - // showControl(); - // await pickFile(); - // showControl(); - // }, - // ), - // ), - IconButton( - tooltip: '${t.storage} ( F )', - icon: const Icon( - Icons.storage_rounded, - size: 18, - ), - onPressed: () async { - showControlForHover( - showPopup( - context: context, - child: const Storages(), - direction: PopupDirection.right, + if (MediaQuery.of(context).size.width >= 420) + DarkTheme( + child: IconButton( + tooltip: '${t.subtitle_and_audio_track} ( S )', + icon: const Icon( + Icons.subtitles_rounded, + size: 20, ), - ); - }, - ), - IconButton( - tooltip: '${t.play_queue} ( P )', - icon: Transform.translate( - offset: const Offset(0, 1.5), - child: const Icon( - Icons.playlist_play_rounded, - size: 28, + onPressed: () async { + showControlForHover( + showPopup( + context: context, + child: SubtitleAndAudioTrack(player: player), + direction: PopupDirection.right, + ), + ); + }, ), ), - onPressed: () async { - showControlForHover( - showPopup( - context: context, - child: const PlayQueue(), - direction: PopupDirection.right, + DarkTheme( + child: IconButton( + tooltip: '${t.play_queue} ( P )', + icon: Transform.translate( + offset: const Offset(0, 1.5), + child: const Icon( + Icons.playlist_play_rounded, + size: 28, ), - ); - }, - ), - IconButton( - tooltip: '${t.subtitle_and_audio_track} ( S )', - icon: const Icon( - Icons.subtitles_rounded, - size: 20, + ), + onPressed: () async { + showControlForHover( + showPopup( + context: context, + child: const PlayQueue(), + direction: PopupDirection.right, + ), + ); + }, ), - onPressed: () async { - showControlForHover( + ), + DarkTheme( + child: IconButton( + tooltip: '${t.storage} ( F )', + icon: const Icon( + Icons.storage_rounded, + size: 18, + ), + onPressed: () => showControlForHover( showPopup( context: context, - child: SubtitleAndAudioTrack(playerCore: playerCore), + child: const Storages(), direction: PopupDirection.right, ), - ); - }, + ), + ), ), Visibility( visible: isDesktop, - child: FutureBuilder( - future: () async { - return (isDesktop && await windowManager.isFullScreen()); - }(), - builder: - (BuildContext context, AsyncSnapshot snapshot) { - final isFullScreen = snapshot.data ?? false; - return IconButton( - tooltip: isFullScreen - ? '${t.exit_fullscreen} ( Escape, F11, Enter )' - : '${t.enter_fullscreen} ( F11, Enter )', - icon: Icon( - isFullScreen - ? Icons.close_fullscreen_rounded - : Icons.open_in_full_rounded, - size: 19, - ), - onPressed: () async { - showControl(); - if (isFullScreen) { - await windowManager.setFullScreen(false); - await resizeWindow(playerCore.videoParams?.aspect); - } else { - await windowManager.setFullScreen(true); - } - }, - ); - }, - ), - ), - // Visibility( - // visible: MediaQuery.of(context).size.width >= 600, - // child: IconButton( - // tooltip: '${t.settings} ( Ctrl + P )', - // icon: const Icon( - // Icons.settings_rounded, - // size: 20, - // ), - // onPressed: () async { - // showControlForHover( - // showPopup( - // context: context, - // child: const Settings(), - // direction: PopupDirection.right, - // ), - // ); - // }, - // ), - // ), - PopupMenuButton( - icon: const Icon( - Icons.more_vert_rounded, - size: 20, - ), - iconColor: Theme.of(context).colorScheme.onSurfaceVariant, - clipBehavior: Clip.hardEdge, - constraints: const BoxConstraints(minWidth: 200), - itemBuilder: (BuildContext context) => [ - PopupMenuItem( - child: ListTile( - mouseCursor: SystemMouseCursors.click, - leading: const Icon( - Icons.file_open_rounded, - size: 16.5, - ), - title: Text(t.open_file), - trailing: Text( - 'Ctrl + O', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).dividerColor, + child: DarkTheme( + child: FutureBuilder( + future: () async { + return (isDesktop && + await windowManager.isFullScreen()); + }(), + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final isFullScreen = snapshot.data ?? false; + return IconButton( + tooltip: isFullScreen + ? '${t.exit_fullscreen} ( Escape, F11, Enter )' + : '${t.enter_fullscreen} ( F11, Enter )', + icon: Icon( + isFullScreen + ? Icons.close_fullscreen_rounded + : Icons.open_in_full_rounded, + size: 19, ), - ), - ), - onTap: () async { - showControl(); - if (isDesktop) { - await pickLocalFile(); - } - if (Platform.isAndroid) { - await pickAndroidFile(); - } - showControl(); + onPressed: () async { + showControl(); + if (isFullScreen) { + await windowManager.setFullScreen(false); + await resizeWindow(player.aspect); + } else { + await windowManager.setFullScreen(true); + } + }, + ); }, ), - PopupMenuItem( - child: ListTile( - mouseCursor: SystemMouseCursors.click, - leading: const Icon( - Icons.file_present_rounded, - size: 16.5, - ), - title: Text(t.open_link), - trailing: Text( - 'Ctrl + L', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).dividerColor, - ), - ), - ), - onTap: () async { - isDesktop - ? await showOpenLinkDialog(context) - : await showOpenLinkBottomSheet(context); - showControl(); - }, + ), + ), + TooltipTheme( + data: TooltipThemeData( + textStyle: TextStyle( + fontSize: 12, + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.onSurface, ), - if (MediaQuery.of(context).size.width < 600) + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + ), + child: PopupMenuButton( + icon: const Icon( + Icons.more_vert_rounded, + size: 20, + ), + iconColor: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.surface, + clipBehavior: Clip.hardEdge, + constraints: const BoxConstraints(minWidth: 200), + itemBuilder: (BuildContext context) => [ PopupMenuItem( child: ListTile( mouseCursor: SystemMouseCursors.click, - leading: Icon( - Icons.shuffle_rounded, - size: 20, - color: !shuffle - ? Theme.of(context).disabledColor - : Theme.of(context) - .colorScheme - .onSurfaceVariant, + leading: const Icon( + Icons.file_open_rounded, + size: 16.5, ), - title: - Text('${t.shuffle}: ${shuffle ? t.on : t.off}'), + title: Text(t.open_file), trailing: Text( - 'Ctrl + X', + 'Ctrl + O', style: TextStyle( fontSize: 12, color: Theme.of(context).dividerColor, ), ), ), - onTap: () { + onTap: () async { + showControl(); + if (isDesktop) { + await pickLocalFile(); + } + if (Platform.isAndroid) { + await pickAndroidFile(); + } showControl(); - shuffle - ? playerController.sortPlayQueue() - : playerController.shufflePlayQueue(); - useAppStore().updateShuffle(!shuffle); }, ), - if (MediaQuery.of(context).size.width < 600) PopupMenuItem( child: ListTile( mouseCursor: SystemMouseCursors.click, - leading: Icon( - repeat == Repeat.one - ? Icons.repeat_one_rounded - : Icons.repeat_rounded, - size: 20, - color: repeat == Repeat.none - ? Theme.of(context).disabledColor - : Theme.of(context) - .colorScheme - .onSurfaceVariant, + leading: const Icon( + Icons.file_present_rounded, + size: 16.5, ), - title: Text(repeat == Repeat.one - ? t.repeat_one - : repeat == Repeat.all - ? t.repeat_all - : t.repeat_none), + title: Text(t.open_link), trailing: Text( - 'Ctrl + R', + 'Ctrl + L', style: TextStyle( fontSize: 12, color: Theme.of(context).dividerColor, ), ), ), - onTap: () { + onTap: () async { + isDesktop + ? await showOpenLinkDialog(context) + : await showOpenLinkBottomSheet(context); showControl(); - useAppStore().toggleRepeat(); }, ), - if (MediaQuery.of(context).size.width < 600) + if (MediaQuery.of(context).size.width < 768) + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + leading: Icon( + Icons.shuffle_rounded, + size: 20, + color: !shuffle + ? Theme.of(context).disabledColor + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + title: + Text('${t.shuffle}: ${shuffle ? t.on : t.off}'), + trailing: Text( + 'Ctrl + X', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).dividerColor, + ), + ), + ), + onTap: () { + showControl(); + shuffle + ? usePlayQueueStore().sort() + : usePlayQueueStore().shuffle(); + useAppStore().updateShuffle(!shuffle); + }, + ), + if (MediaQuery.of(context).size.width < 768) + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + leading: Icon( + repeat == Repeat.one + ? Icons.repeat_one_rounded + : Icons.repeat_rounded, + size: 20, + color: repeat == Repeat.none + ? Theme.of(context).disabledColor + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + title: Text(repeat == Repeat.one + ? t.repeat_one + : repeat == Repeat.all + ? t.repeat_all + : t.repeat_none), + trailing: Text( + 'Ctrl + R', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).dividerColor, + ), + ), + ), + onTap: () { + showControl(); + useAppStore().toggleRepeat(); + }, + ), + if (MediaQuery.of(context).size.width < 768) + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + leading: Icon( + fit == BoxFit.contain + ? Icons.fit_screen_rounded + : fit == BoxFit.fill + ? Icons.aspect_ratio_rounded + : fit == BoxFit.cover + ? Icons.crop_landscape_rounded + : Icons.crop_free_rounded, + size: 20, + ), + title: Text( + '${t.video_zoom}: ${fit == BoxFit.contain ? t.fit : fit == BoxFit.fill ? t.stretch : fit == BoxFit.cover ? t.crop : '100%'}'), + trailing: Text( + 'Ctrl + V', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).dividerColor, + ), + ), + ), + onTap: () { + showControl(); + useAppStore().toggleFit(); + }, + ), + if (MediaQuery.of(context).size.width < 420) + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + leading: const Icon( + Icons.subtitles_rounded, + size: 20, + ), + title: Text(t.subtitle_and_audio_track), + trailing: Text( + 'S', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).dividerColor, + ), + ), + ), + onTap: () => showControlForHover( + showPopup( + context: context, + child: SubtitleAndAudioTrack(player: player), + direction: PopupDirection.right, + ), + ), + ), PopupMenuItem( child: ListTile( mouseCursor: SystemMouseCursors.click, - leading: Icon( - fit == BoxFit.contain - ? Icons.fit_screen_rounded - : fit == BoxFit.fill - ? Icons.aspect_ratio_rounded - : fit == BoxFit.cover - ? Icons.crop_landscape_rounded - : Icons.crop_free_rounded, + leading: const Icon( + Icons.history_rounded, size: 20, ), - title: Text( - '${t.video_zoom}: ${fit == BoxFit.contain ? t.fit : fit == BoxFit.fill ? t.stretch : fit == BoxFit.cover ? t.crop : '100%'}'), + title: Text(t.history), trailing: Text( - 'Ctrl + V', + 'Ctirl + H', style: TextStyle( fontSize: 12, color: Theme.of(context).dividerColor, ), ), ), - onTap: () { - showControl(); - useAppStore().toggleFit(); - }, - ), - PopupMenuItem( - child: ListTile( - mouseCursor: SystemMouseCursors.click, - leading: const Icon( - Icons.history_rounded, - size: 20, - ), - title: Text(t.history), - trailing: Text( - 'Ctirl + H', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).dividerColor, + onTap: () => showControlForHover( + showPopup( + context: context, + child: const History(), + direction: PopupDirection.right, ), ), ), - onTap: () => showControlForHover( - showPopup( - context: context, - child: const History(), - direction: PopupDirection.right, - ), - ), - ), - PopupMenuItem( - child: ListTile( - mouseCursor: SystemMouseCursors.click, - leading: const Icon( - Icons.settings_rounded, - size: 20, - ), - title: Text(t.settings), - trailing: Text( - 'Ctirl + P', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).dividerColor, + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + leading: const Icon( + Icons.settings_rounded, + size: 20, + ), + title: Text(t.settings), + trailing: Text( + 'Ctirl + P', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).dividerColor, + ), ), ), - ), - onTap: () => showControlForHover( - showPopup( - context: context, - child: const Settings(), - direction: PopupDirection.right, + onTap: () => showControlForHover( + showPopup( + context: context, + child: const Settings(), + direction: PopupDirection.right, + ), ), ), - ), - ], + ], + ), ), const SizedBox(width: 8), ], diff --git a/lib/pages/player/control_bar_slider.dart b/lib/pages/player/control_bar_slider.dart index 226a1d5..0b77ffb 100644 --- a/lib/pages/player/control_bar_slider.dart +++ b/lib/pages/player/control_bar_slider.dart @@ -1,26 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:iris/hooks/use_player_controller.dart'; -import 'package:iris/hooks/use_player_core.dart'; +import 'package:iris/models/player.dart'; import 'package:iris/utils/format_duration_to_minutes.dart'; class ControlBarSlider extends HookWidget { const ControlBarSlider({ super.key, - required this.playerCore, + required this.player, required this.showControl, this.disabled = false, }); - final PlayerCore playerCore; + final MediaPlayer player; final void Function() showControl; final bool disabled; @override Widget build(BuildContext context) { - final PlayerController playerController = - usePlayerController(context, playerCore); return ExcludeFocus( child: Container( padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), @@ -29,7 +26,7 @@ class ControlBarSlider extends HookWidget { Visibility( visible: !disabled, child: Text( - formatDurationToMinutes(playerCore.position), + formatDurationToMinutes(player.position), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, height: 2, @@ -58,12 +55,12 @@ class ControlBarSlider extends HookWidget { trackHeight: 3, ), child: Slider( - value: playerCore.buffer.inSeconds.toDouble() > - playerCore.duration.inSeconds.toDouble() + value: player.buffer.inSeconds.toDouble() > + player.duration.inSeconds.toDouble() ? 0 - : playerCore.buffer.inSeconds.toDouble(), + : player.buffer.inSeconds.toDouble(), min: 0, - max: playerCore.duration.inSeconds.toDouble(), + max: player.duration.inSeconds.toDouble(), onChanged: null, ), ), @@ -93,24 +90,29 @@ class ControlBarSlider extends HookWidget { trackHeight: 4, ), child: Slider( - value: playerCore.position.inSeconds.toDouble() > - playerCore.duration.inSeconds.toDouble() + value: player.position.inSeconds.toDouble() > + player.duration.inSeconds.toDouble() ? 0 - : playerCore.position.inSeconds.toDouble(), + : player.position.inSeconds.toDouble(), min: 0, - max: playerCore.duration.inSeconds.toDouble(), + max: player.duration.inSeconds.toDouble(), onChangeStart: (value) { - playerCore.updateSeeking(true); + player.updateSeeking(true); }, onChanged: (value) { showControl(); - playerCore - .updatePosition(Duration(seconds: value.toInt())); + if (player is MediaKitPlayer) { + player + .updatePosition(Duration(seconds: value.toInt())); + } else if (player is FvpPlayer) { + player.seekTo(Duration(seconds: value.toInt())); + } }, onChangeEnd: (value) async { - await playerController - .seekTo(Duration(seconds: value.toInt())); - playerCore.updateSeeking(false); + if (player is MediaKitPlayer) { + await player.seekTo(Duration(seconds: value.toInt())); + } + player.updateSeeking(false); }, ), ), @@ -120,7 +122,7 @@ class ControlBarSlider extends HookWidget { Visibility( visible: !disabled, child: Text( - formatDurationToMinutes(playerCore.duration), + formatDurationToMinutes(player.duration), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, height: 2, diff --git a/lib/pages/player/fvp_video.dart b/lib/pages/player/fvp_video.dart new file mode 100644 index 0000000..9514d19 --- /dev/null +++ b/lib/pages/player/fvp_video.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/store/use_app_store.dart'; +import 'package:video_player/video_player.dart'; + +class FvpVideo extends HookWidget { + const FvpVideo({super.key, required this.player}); + + final FvpPlayer player; + + @override + Widget build(context) { + final fit = useAppStore().select(context, (state) => state.fit); + + return FittedBox( + fit: fit, + child: SizedBox( + width: player.width, + height: player.height, + child: VideoPlayer(player.controller), + ), + ); + } +} diff --git a/lib/pages/player/iris_player.dart b/lib/pages/player/iris_player.dart index 3827a63..261e461 100644 --- a/lib/pages/player/iris_player.dart +++ b/lib/pages/player/iris_player.dart @@ -6,24 +6,28 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/hooks/use_app_lifecycle.dart'; import 'package:iris/hooks/use_brightness.dart'; -import 'package:iris/hooks/use_player_controller.dart'; -import 'package:iris/hooks/use_player_core.dart'; +import 'package:iris/hooks/use_cover.dart'; import 'package:iris/hooks/use_volume.dart'; import 'package:iris/info.dart'; import 'package:iris/models/file.dart'; +import 'package:iris/models/player.dart'; import 'package:iris/models/storages/local.dart'; import 'package:iris/pages/dialog/show_open_link_dialog.dart'; -import 'package:iris/pages/player/audio.dart'; -import 'package:iris/pages/player/control_bar_slider.dart'; import 'package:iris/pages/history.dart'; import 'package:iris/pages/play_queue.dart'; +import 'package:iris/pages/player/audio.dart'; +import 'package:iris/pages/player/control_bar_slider.dart'; +import 'package:iris/pages/player/fvp_video.dart'; import 'package:iris/pages/show_open_link_bottom_sheet.dart'; -import 'package:iris/pages/subtitle_and_audio_track.dart'; import 'package:iris/pages/settings/settings.dart'; +import 'package:iris/pages/subtitle_and_audio_track.dart'; +import 'package:iris/store/use_ui_store.dart'; import 'package:iris/utils/check_content_type.dart'; import 'package:iris/utils/logger.dart'; import 'package:iris/utils/path_conv.dart'; +import 'package:iris/widgets/dark_theme.dart'; import 'package:iris/widgets/popup.dart'; import 'package:iris/pages/storage/storages.dart'; import 'package:iris/store/use_app_store.dart'; @@ -34,16 +38,44 @@ import 'package:iris/utils/is_desktop.dart'; import 'package:iris/utils/resize_window.dart'; import 'package:iris/widgets/custom_app_bar.dart'; import 'package:iris/pages/player/control_bar.dart'; -import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; +enum MediaType { + video, + audio, +} + class IrisPlayer extends HookWidget { - const IrisPlayer({super.key}); + const IrisPlayer({super.key, required this.playerHooks}); + + final MediaPlayer Function(BuildContext) playerHooks; @override Widget build(BuildContext context) { + final MediaPlayer player = playerHooks(context); + + useAppLifecycle(player); + final cover = useCover(context); + + final isHover = useState(false); + final isTouch = useState(false); + final isLongPress = useState(false); + final startPosition = useState(null); + final isHorizontalGesture = useState(false); + final isVerticalGesture = useState(false); + final isLeftGesture = useState(false); + final isRightGesture = useState(false); + + final isShowControl = useState(true); + final isShowProgress = useState(false); + + final controlHideTimer = useRef(null); + final progressHideTimer = useRef(null); + + final brightness = useBrightness(isLeftGesture.value); + final volume = useVolume(isRightGesture.value); + final t = getLocalizations(context); final shuffle = useAppStore().select(context, (state) => state.shuffle); final fit = useAppStore().select(context, (state) => state.fit); @@ -73,95 +105,34 @@ class IrisPlayer extends HookWidget { : INFO.title, [currentPlay, currentPlayIndex, playQueue]); - final focusNode = useFocusNode(); - - final player = useMemoized( - () => Player( - configuration: const PlayerConfiguration( - libass: true, - ), - ), - ); - final controller = useMemoized(() => VideoController(player)); + final mediaType = useMemoized( + () => player.width == null || + player.height == null || + player.width == 0 || + player.height == 0 || + (currentPlay != null && + checkContentType(currentPlay.file.name) == + ContentType.audio) + ? MediaType.audio + : MediaType.video, + [player]); useEffect(() { - () async { - player.setSubtitleTrack(SubtitleTrack.no()); - if (Platform.isAndroid) { - NativePlayer nativePlayer = player.platform as NativePlayer; - - final appSupportDir = await getApplicationSupportDirectory(); - final String fontsDir = "${appSupportDir.path}/fonts"; - - final Directory fontsDirectory = Directory(fontsDir); - if (!await fontsDirectory.exists()) { - await fontsDirectory.create(recursive: true); - logger('fonts directory created'); - } - - final File file = File("$fontsDir/NotoSansCJKsc-Medium.otf"); - if (!await file.exists()) { - final ByteData data = - await rootBundle.load("assets/fonts/NotoSansCJKsc-Medium.otf"); - final Uint8List buffer = data.buffer.asUint8List(); - await file.create(recursive: true); - await file.writeAsBytes(buffer); - logger('NotoSansCJKsc-Medium.otf copied'); - } + if (isDesktop) { + resizeWindow(!autoResize ? 0 : player.aspect); + } + return; + }, [player.aspect, autoResize]); - await nativePlayer.setProperty("sub-fonts-dir", fontsDir); - await nativePlayer.setProperty("sub-font", "NotoSansCJKsc-Medium"); - } - }(); - return player.dispose; - }, []); + final focusNode = useFocusNode(); useEffect(() { focusNode.requestFocus(); return; }, []); - final PlayerCore playerCore = usePlayerCore(context, player); - final PlayerController playerController = - usePlayerController(context, playerCore); - - final isHover = useState(false); - final isTouch = useState(false); - final isLongPress = useState(false); - final startPosition = useState(null); - final isHorizontalGesture = useState(false); - final isVerticalGesture = useState(false); - final isLeftGesture = useState(false); - final isRightGesture = useState(false); - - final controlHideTimer = useRef(null); - final progressHideTimer = useRef(null); - - final isShowControl = useState(true); - final isShowProgress = useState(false); - - final brightness = useBrightness(isLeftGesture.value); - final volume = useVolume(isRightGesture.value); - - AppLifecycleState? appLifecycleState = useAppLifecycleState(); - final canPop = useState(false); - useEffect(() { - if (isDesktop) { - resizeWindow(!autoResize ? 0 : playerCore.videoParams?.aspect); - } - return; - }, [playerCore.videoParams?.aspect, autoResize]); - - useEffect(() { - if (appLifecycleState == AppLifecycleState.paused) { - logger('App lifecycle state: paused'); - playerCore.saveProgress(); - } - return; - }, [appLifecycleState]); - useEffect(() { final timer = Future.delayed(Duration(seconds: 4), () { canPop.value = false; @@ -216,11 +187,15 @@ class IrisPlayer extends HookWidget { } Future showControlForHover(Future callback) async { - playerCore.saveProgress(); - showControl(); - isHover.value = true; - await callback; - showControl(); + try { + player.saveProgress(); + showControl(); + isHover.value = true; + await callback; + showControl(); + } catch (e) { + logger(e.toString()); + } } void showProgress() { @@ -236,15 +211,16 @@ class IrisPlayer extends HookWidget { useEffect(() { return () => progressHideTimer.value?.cancel(); }, []); + useEffect(() { if (isDesktop) { windowManager.setTitle(title); } return; - }, [title, playerCore.playing]); + }, [title, player.isPlaying]); useEffect(() { - if (isShowControl.value || playerCore.mediaType != MediaType.video) { + if (isShowControl.value || mediaType != MediaType.video) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); @@ -265,17 +241,28 @@ class IrisPlayer extends HookWidget { void onKeyEvent(KeyEvent event) async { if (event.runtimeType == KeyDownEvent) { + if (HardwareKeyboard.instance.isAltPressed) { + switch (event.logicalKey) { + // 退出 + case LogicalKeyboardKey.keyX: + showControl(); + await player.saveProgress(); + exit(0); + } + return; + } + if (HardwareKeyboard.instance.isControlPressed) { switch (event.logicalKey) { // 上一个 case LogicalKeyboardKey.arrowLeft: showControl(); - playerController.previous(); + usePlayQueueStore().previous(); break; // 下一个 case LogicalKeyboardKey.arrowRight: showControl(); - playerController.next(); + usePlayQueueStore().next(); break; // 设置 case LogicalKeyboardKey.keyP: @@ -297,8 +284,8 @@ class IrisPlayer extends HookWidget { case LogicalKeyboardKey.keyX: showControl(); shuffle - ? playerController.sortPlayQueue() - : playerController.shufflePlayQueue(); + ? usePlayQueueStore().sort() + : usePlayQueueStore().shuffle(); useAppStore().updateShuffle(!shuffle); break; // 循环 @@ -329,6 +316,17 @@ class IrisPlayer extends HookWidget { : await showOpenLinkBottomSheet(context); showControl(); break; + // 关闭当前播放媒体文件 + case LogicalKeyboardKey.keyC: + showControl(); + player.pause(); + usePlayQueueStore().updateCurrentIndex(-1); + break; + // 静音 + case LogicalKeyboardKey.keyM: + showControl(); + useAppStore().toggleMute(); + break; default: break; } @@ -340,21 +338,21 @@ class IrisPlayer extends HookWidget { case LogicalKeyboardKey.space: case LogicalKeyboardKey.mediaPlayPause: showControl(); - if (playerCore.playing) { - playerController.pause(); + if (player.isPlaying) { + player.pause(); } else { - playerController.play(); + player.play(); } break; // 上一个 case LogicalKeyboardKey.mediaTrackPrevious: - playerController.previous(); + usePlayQueueStore().previous(); showControl(); break; // 下一个 case LogicalKeyboardKey.mediaTrackNext: showControl(); - playerController.next(); + usePlayQueueStore().next(); break; // 存储 case LogicalKeyboardKey.keyF: @@ -381,7 +379,7 @@ class IrisPlayer extends HookWidget { showControlForHover( showPopup( context: context, - child: SubtitleAndAudioTrack(playerCore: playerCore), + child: SubtitleAndAudioTrack(player: player), direction: PopupDirection.right, ), ); @@ -395,11 +393,17 @@ class IrisPlayer extends HookWidget { // 全屏 case LogicalKeyboardKey.enter: case LogicalKeyboardKey.f11: - windowManager.setFullScreen(!await windowManager.isFullScreen()); + if (isDesktop) { + windowManager.setFullScreen(!await windowManager.isFullScreen()); + } break; case LogicalKeyboardKey.tab: showControl(); break; + case LogicalKeyboardKey.f10: + showControl(); + await useUiStore().toggleIsAlwaysOnTop(); + break; default: break; } @@ -415,7 +419,7 @@ class IrisPlayer extends HookWidget { } else { showProgress(); } - playerController.backward(10); + player.backward(10); break; // 快进 case LogicalKeyboardKey.arrowRight: @@ -424,7 +428,17 @@ class IrisPlayer extends HookWidget { } else { showProgress(); } - playerController.forward(10); + player.forward(10); + break; + // 提升音量 + case LogicalKeyboardKey.arrowUp: + showControl(); + await useAppStore().updateVolume(useAppStore().state.volume + 1); + break; + // 降低音量 + case LogicalKeyboardKey.arrowDown: + showControl(); + await useAppStore().updateVolume(useAppStore().state.volume - 1); break; default: break; @@ -440,15 +454,18 @@ class IrisPlayer extends HookWidget { ); final videoViewSize = useMemoized(() { - if (fit != BoxFit.none || - playerCore.videoParams?.w == null || - playerCore.videoParams?.h == null) { + if (fit != BoxFit.none || player.width == 0 || player.height == 0) { return MediaQuery.of(context).size; } else { - return Size(playerCore.videoParams!.w! / scaleFactor, - playerCore.videoParams!.h! / scaleFactor); + return Size(player.width! / scaleFactor, player.height! / scaleFactor); } - }, [fit, MediaQuery.of(context).size, playerCore.videoParams, scaleFactor]); + }, [ + fit, + MediaQuery.of(context).size, + player.width, + player.height, + scaleFactor + ]); final videoViewOffset = useMemoized( () => fit == BoxFit.none @@ -493,7 +510,7 @@ class IrisPlayer extends HookWidget { canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (!didPop) { - await playerCore.saveProgress(); + await player.saveProgress(); if (!canPop.value) { canPop.value = true; if (context.mounted) { @@ -518,7 +535,7 @@ class IrisPlayer extends HookWidget { right: 0, bottom: 0, child: MouseRegion( - cursor: isShowControl.value || !playerCore.playing + cursor: isShowControl.value || player.isPlaying == false ? SystemMouseCursors.basic : SystemMouseCursors.none, onHover: (event) { @@ -549,27 +566,27 @@ class IrisPlayer extends HookWidget { } else { showProgress(); } - await playerController.forward(10); + await player.forward(10); } else if (position < 0.25) { if (isShowControl.value) { showControl(); } else { showProgress(); } - playerController.backward(10); + player.backward(10); } else { - if (playerCore.playing == true) { - playerController.pause(); + if (player.isPlaying == true) { + player.pause(); showControl(); } else { - playerController.play(); + player.play(); } } } else { if (isDesktop) { if (await windowManager.isFullScreen()) { await windowManager.setFullScreen(false); - await resizeWindow(playerCore.videoParams?.aspect); + await resizeWindow(player.aspect); } else { await windowManager.setFullScreen(true); } @@ -577,36 +594,36 @@ class IrisPlayer extends HookWidget { } }, onLongPressStart: (details) { - if (isTouch.value && playerCore.playing) { + if (isTouch.value && player.isPlaying == true) { isLongPress.value = true; - playerController.updateRate(2.0); + player.updateRate(2.0); } }, onLongPressMoveUpdate: (details) { int fast = (details.offsetFromOrigin.dx / 50).toInt(); if (fast >= 1) { - playerController + player .updateRate(fast > 4 ? 5.0 : (1 + fast).toDouble()); } else if (fast <= -1) { - playerController.updateRate(fast < -3 + player.updateRate(fast < -3 ? 0.25 : (1 - 0.25 * fast.abs()).toDouble()); } }, onLongPressEnd: (details) { - playerController.updateRate(1.0); + player.updateRate(1.0); isTouch.value = false; isLongPress.value = false; }, onLongPressCancel: () { - playerController.updateRate(1.0); + player.updateRate(1.0); isTouch.value = false; isLongPress.value = false; }, onPanStart: (details) async { if (isDesktop && details.kind != PointerDeviceKind.touch) { - showControlForHover(windowManager.startDragging()); + windowManager.startDragging(); } else if (details.kind == PointerDeviceKind.touch) { isTouch.value = true; startPosition.value = details.globalPosition; @@ -624,24 +641,24 @@ class IrisPlayer extends HookWidget { !isVerticalGesture.value) { if (dx > dy) { isHorizontalGesture.value = true; - playerCore.updateSeeking(true); + player.updateSeeking(true); } else { isVerticalGesture.value = true; } } // 调整进度 - if (isHorizontalGesture.value && playerCore.seeking) { + if (isHorizontalGesture.value && player.seeking) { double dx = details.delta.dx; int seconds = - (dx * 5 + playerCore.position.inSeconds).toInt(); + (dx * 2 + player.position.inSeconds).toInt(); Duration position = Duration( seconds: seconds < 0 ? 0 - : seconds > playerCore.duration.inSeconds - ? playerCore.duration.inSeconds + : seconds > player.duration.inSeconds + ? player.duration.inSeconds : seconds); - playerCore.updatePosition(position); + player.updatePosition(position); if (isShowControl.value) { showControl(); } else { @@ -690,9 +707,9 @@ class IrisPlayer extends HookWidget { isLeftGesture.value = false; isRightGesture.value = false; startPosition.value = null; - if (playerCore.seeking) { - await playerController.seekTo(playerCore.position); - playerCore.updateSeeking(false); + if (player.seeking) { + await player.seekTo(player.position); + player.updateSeeking(false); } }, onPanCancel: () async { @@ -701,10 +718,10 @@ class IrisPlayer extends HookWidget { isLeftGesture.value = false; isRightGesture.value = false; startPosition.value = null; - if (playerCore.seeking) { + if (player.seeking) { isTouch.value = false; - await playerController.seekTo(playerCore.position); - playerCore.updateSeeking(false); + await player.seekTo(player.position); + player.updateSeeking(false); } }, child: Stack( @@ -723,29 +740,35 @@ class IrisPlayer extends HookWidget { top: videoViewOffset.dy, width: videoViewSize.width, height: videoViewSize.height, - child: Video( - key: ValueKey(currentPlay?.file.getID()), - controller: controller, - controls: NoVideoControls, - fit: fit == BoxFit.none ? BoxFit.contain : fit, - // wakelock: mediaType == 'video', - ), - ) + child: player is FvpPlayer + ? FvpVideo(player: player) + : player is MediaKitPlayer + ? Video( + key: ValueKey(currentPlay?.file.uri), + controller: player.controller, + controls: NoVideoControls, + fit: fit == BoxFit.none + ? BoxFit.contain + : fit, + // wakelock: mediaType == 'video', + ) + : Container(), + ), ], ), ), ), ), // Audio - if (playerCore.mediaType == MediaType.audio) + if (mediaType == MediaType.audio) Positioned( left: 0, top: 0, right: 0, bottom: 0, - child: Audio(playerCore: playerCore)), + child: Audio(cover: cover)), // 播放速度 - if (playerCore.rate != 1.0) + if (player.rate != 1.0) Positioned( left: 0, top: 0, @@ -772,7 +795,7 @@ class IrisPlayer extends HookWidget { ), const SizedBox(width: 10), Text( - playerCore.rate.toString(), + player.rate.toString(), style: const TextStyle( color: Colors.white, fontSize: 20, @@ -871,21 +894,23 @@ class IrisPlayer extends HookWidget { ), if (isShowProgress.value && !isShowControl.value && - playerCore.mediaType != MediaType.audio) + mediaType != MediaType.audio) Positioned( left: -28, right: -28, bottom: -16, height: 32, - child: ControlBarSlider( - playerCore: playerCore, - showControl: showControl, - disabled: true, + child: DarkTheme( + child: ControlBarSlider( + player: player, + showControl: showControl, + disabled: true, + ), ), ), if (isShowProgress.value && !isShowControl.value && - playerCore.mediaType != MediaType.audio) + mediaType != MediaType.audio) Positioned( left: 12, top: 12, @@ -895,7 +920,7 @@ class IrisPlayer extends HookWidget { Text( currentPlay != null ? title : '', style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Colors.white, fontSize: 20, height: 1, decoration: TextDecoration.none, @@ -913,7 +938,7 @@ class IrisPlayer extends HookWidget { ), if (isShowProgress.value && !isShowControl.value && - playerCore.mediaType != MediaType.audio) + mediaType != MediaType.audio) Positioned( left: 12, bottom: 6, @@ -921,9 +946,9 @@ class IrisPlayer extends HookWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${formatDurationToMinutes(playerCore.position)} / ${formatDurationToMinutes(playerCore.duration)}', + '${formatDurationToMinutes(player.position)} / ${formatDurationToMinutes(player.duration)}', style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Colors.white, fontSize: 16, height: 2, decoration: TextDecoration.none, @@ -943,8 +968,7 @@ class IrisPlayer extends HookWidget { AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeInOutCubicEmphasized, - top: isShowControl.value || - playerCore.mediaType != MediaType.video + top: isShowControl.value || mediaType != MediaType.video ? 0 : -72, left: 0, @@ -961,22 +985,24 @@ class IrisPlayer extends HookWidget { onDoubleTap: () async { if (isDesktop && await windowManager.isMaximized()) { await windowManager.unmaximize(); - await resizeWindow(playerCore.videoParams?.aspect); + await resizeWindow(player.aspect); } else { await windowManager.maximize(); } }, onPanStart: (details) async { if (isDesktop) { - showControlForHover(windowManager.startDragging()); + windowManager.startDragging(); } }, - child: CustomAppBar( - title: title, - playerCore: playerCore, - actions: [ - const SizedBox(width: 8), - ], + child: DarkTheme( + child: CustomAppBar( + title: title, + player: player, + actions: [ + const SizedBox(width: 8), + ], + ), ), ), ), @@ -985,8 +1011,7 @@ class IrisPlayer extends HookWidget { AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeInOutCubicEmphasized, - bottom: isShowControl.value || - playerCore.mediaType != MediaType.video + bottom: isShowControl.value || mediaType != MediaType.video ? 0 : -96, left: 0, @@ -1005,7 +1030,7 @@ class IrisPlayer extends HookWidget { child: GestureDetector( onTap: () => showControl(), child: ControlBar( - playerCore: playerCore, + player: player, showControl: showControl, showControlForHover: showControlForHover, ), diff --git a/lib/pages/player/volume_control.dart b/lib/pages/player/volume_control.dart new file mode 100644 index 0000000..e36b085 --- /dev/null +++ b/lib/pages/player/volume_control.dart @@ -0,0 +1,93 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/pages/player/volume_slider.dart'; +import 'package:iris/store/use_app_store.dart'; +import 'package:iris/utils/get_localizations.dart'; +import 'package:popover/popover.dart'; + +Future showVolumePopover( + BuildContext context, + void Function() showControl, +) async => + showPopover( + context: context, + bodyBuilder: (context) => Container( + padding: EdgeInsets.fromLTRB(8, 0, 16, 0), + child: VolumeControl(showControl: showControl), + ), + direction: PopoverDirection.top, + width: 240, + height: 48, + arrowHeight: 0, + arrowWidth: 0, + backgroundColor: Theme.of(context).colorScheme.surface, + barrierColor: Colors.transparent, + ); + +class VolumeControl extends HookWidget { + const VolumeControl({ + super.key, + required this.showControl, + this.showVolumeText = true, + }); + + final void Function() showControl; + final bool showVolumeText; + + @override + Widget build(BuildContext context) { + final t = getLocalizations(context); + final volume = useAppStore().select(context, (state) => state.volume); + final isMuted = useAppStore().select(context, (state) => state.isMuted); + return Listener( + onPointerSignal: (PointerSignalEvent event) async { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy < 0) { + showControl(); + if (isMuted) { + await useAppStore().updateVolume(0); + await useAppStore().updateMute(false); + } else { + useAppStore().updateVolume(volume + 2); + } + } else { + showControl(); + useAppStore().updateVolume(volume - 2); + } + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + textBaseline: TextBaseline.ideographic, + children: [ + IconButton( + tooltip: '${isMuted ? t.unmute : t.mute} ( Ctrl + M )', + icon: Icon( + isMuted || volume == 0 + ? Icons.volume_off_rounded + : volume < 50 + ? Icons.volume_down_rounded + : Icons.volume_up_rounded, + size: 20, + ), + onPressed: () { + showControl(); + if (volume == 0) { + useAppStore().updateVolume(80); + } else { + useAppStore().toggleMute(); + } + }, + ), + Expanded( + child: VolumeSlider(showControl: showControl), + ), + if (showVolumeText) const SizedBox(width: 8), + if (showVolumeText) Text('${volume >= 100 ? '' : ' '}$volume'), + ], + ), + ); + } +} diff --git a/lib/pages/player/volume_slider.dart b/lib/pages/player/volume_slider.dart new file mode 100644 index 0000000..f9f25b8 --- /dev/null +++ b/lib/pages/player/volume_slider.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/store/use_app_store.dart'; + +class VolumeSlider extends HookWidget { + const VolumeSlider({super.key, required this.showControl}); + + final void Function() showControl; + + @override + Widget build(BuildContext context) { + final volume = useAppStore().select(context, (state) => state.volume); + final isMuted = useAppStore().select(context, (state) => state.isMuted); + + return SizedBox( + width: 128, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + thumbColor: Theme.of(context).colorScheme.onSurfaceVariant, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: 5.6, + ), + disabledThumbColor: + Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(222), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 4, + ), + activeTrackColor: Theme.of(context).colorScheme.onSurfaceVariant, + inactiveTrackColor: + Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(99), + trackHeight: 3.6, + ), + child: Slider( + value: isMuted ? 0 : volume.toDouble(), + onChanged: (value) { + showControl(); + useAppStore().updateMute(false); + useAppStore().updateVolume((value).toInt()); + }, + min: 0, + max: 100, + activeColor: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/lib/pages/settings/play.dart b/lib/pages/settings/play.dart index 97b300a..5a36c17 100644 --- a/lib/pages/settings/play.dart +++ b/lib/pages/settings/play.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/models/store/app_state.dart'; import 'package:iris/store/use_app_store.dart'; import 'package:iris/utils/get_localizations.dart'; import 'package:iris/utils/is_desktop.dart'; @@ -16,10 +17,30 @@ class Play extends HookWidget { useAppStore().select(context, (state) => state.autoResize); final bool alwaysPlayFromBeginning = useAppStore().select(context, (state) => state.alwaysPlayFromBeginning); + final playerBackend = + useAppStore().select(context, (state) => state.playerBackend); return SingleChildScrollView( child: Column( children: [ + ListTile( + leading: const Icon(Icons.settings_input_component_rounded), + title: Text(t.player_backend), + trailing: DropdownButton( + borderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.symmetric(horizontal: 8), + value: playerBackend, + onChanged: (value) { + if (value != null) useAppStore().updatePlayerBackend(value); + }, + items: [ + DropdownMenuItem( + value: PlayerBackend.mediaKit, child: Text('Media Kit')), + DropdownMenuItem( + value: PlayerBackend.fvp, + child: Text('FVP (${t.experimental})')), + ], + )), Visibility( visible: isDesktop, child: ListTile( diff --git a/lib/pages/storage/files.dart b/lib/pages/storage/files.dart index 93999c2..0b54678 100644 --- a/lib/pages/storage/files.dart +++ b/lib/pages/storage/files.dart @@ -8,6 +8,7 @@ import 'package:iris/globals.dart' as globals; import 'package:iris/models/file.dart'; import 'package:iris/models/progress.dart'; import 'package:iris/models/storages/storage.dart'; +import 'package:iris/models/store/app_state.dart'; import 'package:iris/models/store/storage_state.dart'; import 'package:iris/store/use_app_store.dart'; import 'package:iris/store/use_history_store.dart'; @@ -15,6 +16,7 @@ import 'package:iris/store/use_play_queue_store.dart'; import 'package:iris/store/use_storage_store.dart'; import 'package:iris/utils/files_filter.dart'; import 'package:iris/utils/file_size_convert.dart'; +import 'package:iris/utils/files_sort.dart'; import 'package:iris/utils/get_localizations.dart'; import 'package:iris/utils/request_storage_permission.dart'; import 'package:iris/widgets/custom_chip.dart'; @@ -33,7 +35,10 @@ class Files extends HookWidget { final refreshState = useState(false); void refresh() => refreshState.value = !refreshState.value; - final basePath = storage.basePath; + final sortBy = useAppStore().select(context, (state) => state.sortBy); + final sortOrder = useAppStore().select(context, (state) => state.sortOrder); + final folderFirst = + useAppStore().select(context, (state) => state.folderFirst); final favorites = useStorageStore().select(context, (state) => state.favorites); @@ -48,7 +53,7 @@ class Files extends HookWidget { useEffect(() { if (currentPath.isEmpty) { - useStorageStore().updateCurrentPath(basePath); + useStorageStore().updateCurrentPath(storage.basePath); } return null; }, []); @@ -58,15 +63,24 @@ class Files extends HookWidget { [currentPath, refreshState.value]); final result = useFuture(getFiles); - - final List files = result.data ?? []; - final isLoading = result.connectionState == ConnectionState.waiting; - final error = result.error != null; + final isLoading = useMemoized( + () => result.connectionState == ConnectionState.waiting, + [result.connectionState]); + final isError = result.error != null; final filteredFiles = useMemoized( - () => filesFilter( - files, [ContentType.dir, ContentType.video, ContentType.audio]), - [files]); + () => filesFilter(result.data ?? [], + [ContentType.dir, ContentType.video, ContentType.audio]), + [result.data]); + + final files = useMemoized( + () => filesSort( + files: filteredFiles, + sortBy: sortBy, + sortOrder: sortOrder, + folderFirst: folderFirst, + ), + [filteredFiles, sortBy, sortOrder, folderFirst]); ItemScrollController itemScrollController = ItemScrollController(); ScrollOffsetController scrollOffsetController = ScrollOffsetController(); @@ -91,7 +105,7 @@ class Files extends HookWidget { } void back() { - if (currentPath.length > basePath.length) { + if (currentPath.length > storage.basePath.length) { useStorageStore() .updateCurrentPath(currentPath.sublist(0, currentPath.length - 1)); } else { @@ -117,9 +131,9 @@ class Files extends HookWidget { ) : isLoading ? const Center(child: CircularProgressIndicator()) - : error + : isError ? Center(child: Text(t.unable_to_fetch_files)) - : filteredFiles.isEmpty + : files.isEmpty ? const Center() : Card( color: Colors.transparent, @@ -132,14 +146,14 @@ class Files extends HookWidget { scrollOffsetController: scrollOffsetController, itemPositionsListener: itemPositionsListener, scrollOffsetListener: scrollOffsetListener, - itemCount: filteredFiles.length, + itemCount: files.length, itemBuilder: (context, index) => ListTile( contentPadding: const EdgeInsets.fromLTRB(16, 0, 8, 0), visualDensity: const VisualDensity( horizontal: 0, vertical: -4), leading: () { - switch (filteredFiles[index].type) { + switch (files[index].type) { case ContentType.dir: return const Icon(Icons.folder_rounded); case ContentType.video: @@ -155,78 +169,95 @@ class Files extends HookWidget { } }(), title: Text( - filteredFiles[index].name, + files[index].name, maxLines: 3, overflow: TextOverflow.ellipsis, ), - subtitle: filteredFiles[index].size != 0 - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "${fileSizeConvert(filteredFiles[index].size)} MB", - style: const TextStyle( - fontSize: 13, - ), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + textBaseline: TextBaseline.ideographic, + children: [ + if (files[index].size != 0) + Text( + "${fileSizeConvert(files[index].size)} MB", + style: const TextStyle( + fontSize: 13, + ), + ), + if (files[index].size != 0) + const SizedBox(width: 8), + if (files[index].lastModified != null) + Expanded( + child: Text( + files[index] + .lastModified + .toString() + .split('.')[0], + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.8), + fontWeight: FontWeight.w400, + overflow: TextOverflow.ellipsis, ), - const Spacer(), - () { - final Progress? progress = - useHistoryStore().findById( - filteredFiles[index] - .getID()); - if (progress != null && - progress.file.type == - ContentType.video) { - if ((progress.duration - .inMilliseconds - - progress.position - .inMilliseconds) <= - 5000) { - return CustomChip( - text: '100%'); - } - final String progressString = - (progress.position - .inMilliseconds / - progress.duration - .inMilliseconds * - 100) - .toStringAsFixed(0); - return CustomChip( - text: '$progressString %'); - } else { - return const SizedBox(); - } - }(), - ...filteredFiles[index] - .subtitles - .map((subtitle) => subtitle.uri - .split('.') - .last - .toUpperCase()) - .toSet() - .toList() - .map( - (subtitleType) => Row( - mainAxisSize: - MainAxisSize.min, - children: [ - const SizedBox(width: 8), - CustomChip( - text: subtitleType, - primary: true, - ), - ], - ), + ), + ), + if (files[index].size != 0) + const SizedBox(width: 8), + () { + final Progress? progress = + useHistoryStore() + .findById(files[index].getID()); + if (progress != null && + progress.file.type == + ContentType.video) { + if ((progress + .duration.inMilliseconds - + progress.position + .inMilliseconds) <= + 5000) { + return CustomChip(text: '100%'); + } + final String progressString = + (progress.position + .inMilliseconds / + progress.duration + .inMilliseconds * + 100) + .toStringAsFixed(0); + return CustomChip( + text: '$progressString %'); + } else { + return const SizedBox(); + } + }(), + ...files[index] + .subtitles + .map((subtitle) => subtitle.uri + .split('.') + .last + .toUpperCase()) + .toSet() + .toList() + .map( + (subtitleType) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 4), + CustomChip( + text: subtitleType, + primary: true, ), - ], - ) - : null, - trailing: filteredFiles[index].type == + ], + ), + ), + ], + ), + trailing: files[index].type == ContentType.video || - filteredFiles[index].type == - ContentType.audio + files[index].type == ContentType.audio ? PopupMenuButton( clipBehavior: Clip.hardEdge, constraints: const BoxConstraints( @@ -234,8 +265,8 @@ class Files extends HookWidget { onSelected: (value) async { switch (value) { case FileOptions.addToPlayQueue: - usePlayQueueStore().add( - [filteredFiles[index]]); + usePlayQueueStore() + .add([files[index]]); break; default: break; @@ -250,18 +281,16 @@ class Files extends HookWidget { ) : null, onTap: () { - if (filteredFiles[index].isDir == true && - filteredFiles[index].name.isNotEmpty) { - useStorageStore().updateCurrentPath([ - ...currentPath, - filteredFiles[index].name - ]); + if (files[index].isDir == true && + files[index].name.isNotEmpty) { + useStorageStore().updateCurrentPath( + [...currentPath, files[index].name]); } else { - if (filteredFiles[index].type == + if (files[index].type == ContentType.video || - filteredFiles[index].type == + files[index].type == ContentType.audio) { - play(filteredFiles, index); + play(files, index); Navigator.pop(context); } } @@ -321,6 +350,82 @@ class Files extends HookWidget { icon: const Icon(Icons.refresh), onPressed: refresh, ), + PopupMenuButton( + tooltip: t.sort, + icon: const Icon(Icons.sort_rounded), + clipBehavior: Clip.hardEdge, + constraints: const BoxConstraints(minWidth: 200), + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + title: Text(t.name), + trailing: sortBy == SortBy.name + ? Icon(sortOrder == SortOrder.asc + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded) + : null, + ), + onTap: () { + useAppStore().updateSortBy(SortBy.name); + useAppStore().updateSortOrder( + sortOrder == SortOrder.desc || sortBy != SortBy.name + ? SortOrder.asc + : SortOrder.desc); + }, + ), + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + title: Text(t.size), + trailing: sortBy == SortBy.size + ? Icon(sortOrder == SortOrder.asc + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded) + : null, + ), + onTap: () { + useAppStore().updateSortBy(SortBy.size); + useAppStore().updateSortOrder( + sortOrder == SortOrder.asc || sortBy != SortBy.size + ? SortOrder.desc + : SortOrder.asc); + }, + ), + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + title: Text(t.last_modified), + trailing: sortBy == SortBy.lastModified + ? Icon(sortOrder == SortOrder.asc + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded) + : null, + ), + onTap: () { + useAppStore().updateSortBy(SortBy.lastModified); + useAppStore().updateSortOrder( + sortOrder == SortOrder.asc || + sortBy != SortBy.lastModified + ? SortOrder.desc + : SortOrder.asc); + }, + ), + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + title: Text(t.folder_first), + trailing: Checkbox( + value: folderFirst, + onChanged: (_) { + useAppStore().updateFolderFirst(!folderFirst); + Navigator.pop(context); + }), + ), + onTap: () => useAppStore().updateFolderFirst(!folderFirst), + ), + ], + ), IconButton( tooltip: currentFavorite != null ? t.remove_favorite diff --git a/lib/pages/subtitle_and_audio_track.dart b/lib/pages/subtitle_and_audio_track.dart index 4b978db..6c012ac 100644 --- a/lib/pages/subtitle_and_audio_track.dart +++ b/lib/pages/subtitle_and_audio_track.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:iris/hooks/use_player_core.dart'; -import 'package:iris/pages/audio_tracks.dart'; -import 'package:iris/pages/subtitles.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/pages/audio_track_list.dart'; +import 'package:iris/pages/subtitle_list.dart'; import 'package:iris/utils/get_localizations.dart'; class ITab { @@ -16,17 +16,17 @@ class ITab { } class SubtitleAndAudioTrack extends HookWidget { - const SubtitleAndAudioTrack({super.key, required this.playerCore}); + const SubtitleAndAudioTrack({super.key, required this.player}); - final PlayerCore playerCore; + final MediaPlayer player; @override Widget build(BuildContext context) { final t = getLocalizations(context); List tabs = [ - ITab(title: t.subtitle, child: Subtitles(playerCore: playerCore)), - ITab(title: t.audio_track, child: AudioTracks(playerCore: playerCore)), + ITab(title: t.subtitle, child: SubtitleList(player: player)), + ITab(title: t.audio_track, child: AudioTrackList(player: player)), ]; final tabController = useTabController(initialLength: tabs.length); diff --git a/lib/pages/subtitle_list.dart b/lib/pages/subtitle_list.dart new file mode 100644 index 0000000..44be22c --- /dev/null +++ b/lib/pages/subtitle_list.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fvp/fvp.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/utils/get_localizations.dart'; +import 'package:iris/utils/logger.dart'; +import 'package:media_kit/media_kit.dart'; + +class SubtitleList extends HookWidget { + const SubtitleList({super.key, required this.player}); + + final MediaPlayer player; + + @override + Widget build(BuildContext context) { + final t = getLocalizations(context); + + final focusNode = useFocusNode(); + + useEffect(() { + focusNode.requestFocus(); + return () => focusNode.unfocus(); + }, []); + + if (player is MediaKitPlayer) { + return ListView( + children: [ + ...(player as MediaKitPlayer).subtitles.map( + (subtitle) => ListTile( + focusNode: (player as MediaKitPlayer).subtitle == subtitle + ? focusNode + : null, + title: Text( + subtitle == SubtitleTrack.no() + ? t.off + : subtitle.title ?? subtitle.language ?? subtitle.id, + style: (player as MediaKitPlayer).subtitle == subtitle + ? TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ) + : TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger( + 'Set subtitle: ${subtitle.title ?? subtitle.language ?? subtitle.id}'); + (player as MediaKitPlayer) + .player + .setSubtitleTrack(subtitle); + Navigator.of(context).pop(); + }, + ), + ), + ...(player as MediaKitPlayer).externalSubtitles.map( + (subtitle) => ListTile( + title: Text( + subtitle.name, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger('Set external subtitle: ${subtitle.name}'); + (player as MediaKitPlayer).player.setSubtitleTrack( + SubtitleTrack.uri( + subtitle.uri, + title: subtitle.name, + ), + ); + Navigator.of(context).pop(); + }, + ), + ), + ], + ); + } + + if (player is FvpPlayer) { + final subtitles = + (player as FvpPlayer).controller.getMediaInfo()?.subtitle ?? []; + final activeSubtitles = + (player as FvpPlayer).controller.getActiveSubtitleTracks() ?? []; + return ListView( + children: [ + ListTile( + focusNode: (player as FvpPlayer).externalSubtitle.value == null && + activeSubtitles.isEmpty + ? focusNode + : null, + title: Text( + t.off, + style: (player as FvpPlayer).externalSubtitle.value == null && + activeSubtitles.isEmpty + ? TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ) + : TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger('Set subtitle: ${t.off}'); + (player as FvpPlayer).externalSubtitle.value = null; + (player as FvpPlayer).controller.setSubtitleTracks([]); + Navigator.of(context).pop(); + }, + ), + ...subtitles.map( + (subtitle) => ListTile( + focusNode: (player as FvpPlayer).externalSubtitle.value == null && + activeSubtitles.contains(subtitles.indexOf(subtitle)) + ? focusNode + : null, + title: Text( + subtitle.metadata['title'] ?? + subtitle.metadata['language'] ?? + subtitle.index.toString(), + style: (player as FvpPlayer).externalSubtitle.value == null && + activeSubtitles.contains(subtitles.indexOf(subtitle)) + ? TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ) + : TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger( + 'Set subtitle: ${subtitle.metadata['title'] ?? subtitle.metadata['language'] ?? subtitle.index.toString()}'); + (player as FvpPlayer).externalSubtitle.value = null; + (player as FvpPlayer) + .controller + .setSubtitleTracks([subtitles.indexOf(subtitle)]); + Navigator.of(context).pop(); + }, + ), + ), + ...(player as FvpPlayer).externalSubtitles.map( + (subtitle) => ListTile( + focusNode: (player as FvpPlayer).externalSubtitle.value == + (player as FvpPlayer) + .externalSubtitles + .indexOf(subtitle) + ? focusNode + : null, + title: (player as FvpPlayer).externalSubtitle.value == + (player as FvpPlayer) + .externalSubtitles + .indexOf(subtitle) + ? Text( + subtitle.name, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary), + ) + : Text( + subtitle.name, + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onTap: () { + logger('Set external subtitle: ${subtitle.name}'); + (player as FvpPlayer).externalSubtitle.value = + (player as FvpPlayer) + .externalSubtitles + .indexOf(subtitle); + Navigator.of(context).pop(); + }, + ), + ), + ], + ); + } + + return Container(); + } +} diff --git a/lib/pages/subtitles.dart b/lib/pages/subtitles.dart deleted file mode 100644 index 8e8947b..0000000 --- a/lib/pages/subtitles.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:iris/hooks/use_player_core.dart'; -import 'package:iris/utils/get_localizations.dart'; -import 'package:iris/utils/logger.dart'; -import 'package:media_kit/media_kit.dart'; - -class Subtitles extends HookWidget { - const Subtitles({super.key, required this.playerCore}); - - final PlayerCore playerCore; - - @override - Widget build(BuildContext context) { - final t = getLocalizations(context); - - final focusNode = useFocusNode(); - - useEffect(() { - focusNode.requestFocus(); - return () => focusNode.unfocus(); - }, []); - - return ListView( - children: [ - ...playerCore.subtitles.map( - (subtitle) => ListTile( - focusNode: playerCore.subtitle == subtitle ? focusNode : null, - title: Text( - subtitle == SubtitleTrack.no() - ? t.off - : subtitle.title ?? subtitle.language ?? subtitle.id, - style: playerCore.subtitle == subtitle - ? TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ) - : TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - onTap: () { - logger( - 'Set subtitle: ${subtitle.title ?? subtitle.language ?? subtitle.id}'); - playerCore.player.setSubtitleTrack(subtitle); - Navigator.of(context).pop(); - }, - ), - ), - ...playerCore.externalSubtitles.map( - (subtitle) => ListTile( - title: Text( - subtitle.name, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - onTap: () { - logger('Set external subtitle: ${subtitle.name}'); - playerCore.player.setSubtitleTrack( - SubtitleTrack.uri( - subtitle.uri, - title: subtitle.name, - ), - ); - Navigator.of(context).pop(); - }, - ), - ), - ], - ); - } -} diff --git a/lib/store/use_app_store.dart b/lib/store/use_app_store.dart index b9640c7..4d48f64 100644 --- a/lib/store/use_app_store.dart +++ b/lib/store/use_app_store.dart @@ -62,6 +62,26 @@ class AppStore extends PersistentStore { await save(state); } + Future updateVolume(int volume) async { + set(state.copyWith( + volume: volume < 0 + ? 0 + : volume > 100 + ? 100 + : volume)); + await save(state); + } + + Future updateMute(bool isMuted) async { + set(state.copyWith(isMuted: isMuted)); + await save(state); + } + + Future toggleMute() async { + set(state.copyWith(isMuted: !state.isMuted)); + save(state); + } + Future updateThemeMode(ThemeMode themeMode) async { set(state.copyWith(themeMode: themeMode)); await save(state); @@ -83,6 +103,26 @@ class AppStore extends PersistentStore { await save(state); } + Future updatePlayerBackend(PlayerBackend backend) async { + set(state.copyWith(playerBackend: backend)); + await save(state); + } + + Future updateSortBy(SortBy sortBy) async { + set(state.copyWith(sortBy: sortBy)); + await save(state); + } + + Future updateSortOrder(SortOrder sortOrder) async { + set(state.copyWith(sortOrder: sortOrder)); + await save(state); + } + + Future updateFolderFirst(bool folderFirst) async { + set(state.copyWith(folderFirst: folderFirst)); + await save(state); + } + @override Future load() async { logger('Loading AppState'); diff --git a/lib/store/use_play_queue_store.dart b/lib/store/use_play_queue_store.dart index 86df4bf..3ca9b54 100644 --- a/lib/store/use_play_queue_store.dart +++ b/lib/store/use_play_queue_store.dart @@ -10,6 +10,7 @@ import 'package:iris/store/persistent_store.dart'; import 'package:iris/globals.dart' as globals; import 'package:iris/store/use_app_store.dart'; import 'package:iris/utils/check_content_type.dart'; +import 'package:iris/utils/get_shuffle_play_queue.dart'; import 'package:iris/utils/is_desktop.dart'; import 'package:iris/utils/logger.dart'; import 'package:iris/utils/path_conv.dart'; @@ -96,6 +97,31 @@ class PlayQueueStore extends PersistentStore { await save(state); } + Future previous() async { + final int currentPlayIndex = state.playQueue + .indexWhere((element) => element.index == state.currentIndex); + if (currentPlayIndex <= 0) return; + await updateCurrentIndex(state.playQueue[currentPlayIndex - 1].index); + } + + Future next() async { + final int currentPlayIndex = state.playQueue + .indexWhere((element) => element.index == state.currentIndex); + if (currentPlayIndex >= state.playQueue.length - 1) return; + await updateCurrentIndex(state.playQueue[currentPlayIndex + 1].index); + } + + Future shuffle() async => update( + playQueue: getShufflePlayQueue(state.playQueue, state.currentIndex), + index: state.currentIndex, + ); + + Future sort() async => update( + playQueue: [...state.playQueue] + ..sort((a, b) => a.index.compareTo(b.index)), + index: state.currentIndex, + ); + @override Future load() async { logger('Loading PlayQueueState'); diff --git a/lib/store/use_ui_store.dart b/lib/store/use_ui_store.dart new file mode 100644 index 0000000..7c52151 --- /dev/null +++ b/lib/store/use_ui_store.dart @@ -0,0 +1,17 @@ +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/models/store/ui_state.dart'; +import 'package:iris/utils/is_desktop.dart'; +import 'package:window_manager/window_manager.dart'; + +class UiStore extends Store { + UiStore() : super(const UiState()); + + Future toggleIsAlwaysOnTop() async { + if (isDesktop) { + windowManager.setAlwaysOnTop(!state.isAlwaysOnTop); + set(state.copyWith(isAlwaysOnTop: !state.isAlwaysOnTop)); + } + } +} + +UiStore useUiStore() => create(() => UiStore()); diff --git a/lib/theme.dart b/lib/theme.dart index 52e155b..660b999 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -47,6 +47,7 @@ CustomTheme getTheme({ useMaterial3: true, textTheme: GoogleFonts.notoSansScTextTheme(), popupMenuTheme: baseTheme(context).popupMenuTheme, + dropdownMenuTheme: baseTheme(context).dropdownMenuTheme, listTileTheme: baseTheme(context).listTileTheme, ); @@ -58,6 +59,7 @@ CustomTheme getTheme({ .textTheme, ), popupMenuTheme: baseTheme(context).popupMenuTheme, + dropdownMenuTheme: baseTheme(context).dropdownMenuTheme, listTileTheme: baseTheme(context).listTileTheme, ); diff --git a/lib/utils/check_data_source_type.dart b/lib/utils/check_data_source_type.dart new file mode 100644 index 0000000..7b12a2c --- /dev/null +++ b/lib/utils/check_data_source_type.dart @@ -0,0 +1,20 @@ +import 'dart:io'; +import 'package:iris/models/file.dart'; +import 'package:iris/models/storages/storage.dart'; +import 'package:video_player/video_player.dart'; + +DataSourceType checkDataSourceType(FileItem file) { + if (Platform.isAndroid && file.uri.startsWith('content://')) { + return DataSourceType.contentUri; + } + + switch (file.storageType) { + case StorageType.internal: + case StorageType.sdcard: + case StorageType.usb: + return DataSourceType.file; + case StorageType.webdav: + case StorageType.none: + return DataSourceType.network; + } +} diff --git a/lib/utils/files_sort.dart b/lib/utils/files_sort.dart index 1d40de9..f123815 100644 --- a/lib/utils/files_sort.dart +++ b/lib/utils/files_sort.dart @@ -1,14 +1,65 @@ import 'package:iris/models/file.dart'; +import 'package:iris/models/store/app_state.dart'; -List filesSort(List files, bool directoryFirst) { - if (directoryFirst) { - final dirs_ = files.where((file) => file.isDir).toList(); - final files_ = files.where((file) => !file.isDir).toList(); - dirs_.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - files_.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); +List filesSort({ + required List files, + SortBy sortBy = SortBy.name, + SortOrder sortOrder = SortOrder.asc, + bool folderFirst = true, +}) { + final dirs_ = files.where((file) => file.isDir).toList(); + final files_ = files.where((file) => !file.isDir).toList(); + + int compare(dynamic a, dynamic b) { + int result; + if (a is String && b is String) { + result = a.toLowerCase().compareTo(b.toLowerCase()); + } else if (a is Comparable && b is Comparable) { + result = a.compareTo(b); + } else { + result = 0; + } + + return sortOrder == SortOrder.asc ? result : -result; + } + + if (folderFirst) { + switch (sortBy) { + case SortBy.name: + dirs_.sort((a, b) => compare(a.name, b.name)); + files_.sort((a, b) => compare(a.name, b.name)); + break; + case SortBy.size: + dirs_.sort((a, b) => compare(a.size, b.size)); + files_.sort((a, b) => compare(a.size, b.size)); + break; + case SortBy.lastModified: + dirs_.sort((a, b) => compare( + a.lastModified ?? DateTime(0), + b.lastModified ?? DateTime(0), + )); + files_.sort((a, b) => compare( + a.lastModified ?? DateTime(0), + b.lastModified ?? DateTime(0), + )); + break; + } return [...dirs_, ...files_]; } else { - files.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + switch (sortBy) { + case SortBy.name: + files.sort((a, b) => compare(a.name, b.name)); + break; + case SortBy.size: + files.sort((a, b) => compare(a.size, b.size)); + break; + case SortBy.lastModified: + files.sort((a, b) => compare( + a.lastModified ?? DateTime(0), + b.lastModified ?? DateTime(0), + )); + break; + } return files; } } diff --git a/lib/widgets/custom_app_bar.dart b/lib/widgets/custom_app_bar.dart index 293caef..7ade9d8 100644 --- a/lib/widgets/custom_app_bar.dart +++ b/lib/widgets/custom_app_bar.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:iris/hooks/use_player_core.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; import 'package:iris/info.dart'; +import 'package:iris/models/player.dart'; +import 'package:iris/store/use_ui_store.dart'; import 'package:iris/utils/get_localizations.dart'; import 'package:iris/utils/is_desktop.dart'; import 'package:iris/utils/resize_window.dart'; @@ -11,16 +13,18 @@ class CustomAppBar extends HookWidget { const CustomAppBar({ super.key, this.title, - required this.playerCore, + required this.player, this.actions, }); final String? title; - final PlayerCore playerCore; + final MediaPlayer player; final List? actions; @override Widget build(BuildContext context) { final t = getLocalizations(context); + final isAlwaysOnTop = + useUiStore().select(context, (state) => state.isAlwaysOnTop); return Container( padding: isDesktop @@ -31,9 +35,9 @@ class CustomAppBar extends HookWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), - Theme.of(context).colorScheme.surface.withValues(alpha: 0.3), - Theme.of(context).colorScheme.surface.withValues(alpha: 0), + Colors.black87.withValues(alpha: 0.8), + Colors.black87.withValues(alpha: 0.3), + Colors.black87.withValues(alpha: 0), ], ), ), @@ -85,24 +89,17 @@ class CustomAppBar extends HookWidget { children: [ Visibility( visible: !isFullScreen, - child: FutureBuilder( - future: windowManager.isAlwaysOnTop(), - builder: (context, snapshot) { - bool isAlwaysOnTop = snapshot.data ?? false; - return IconButton( - tooltip: isAlwaysOnTop - ? t.always_on_top_on - : t.always_on_top_off, - icon: Icon( - isAlwaysOnTop - ? Icons.push_pin_rounded - : Icons.push_pin_outlined, - size: 18, - ), - onPressed: () => windowManager - .setAlwaysOnTop(!isAlwaysOnTop), - ); - }, + child: IconButton( + tooltip: isAlwaysOnTop + ? '${t.always_on_top_on} ( F10 )' + : '${t.always_on_top_off} ( F10 )', + icon: Icon( + isAlwaysOnTop + ? Icons.push_pin_rounded + : Icons.push_pin_outlined, + size: 18, + ), + onPressed: useUiStore().toggleIsAlwaysOnTop, ), ), Visibility( @@ -120,8 +117,7 @@ class CustomAppBar extends HookWidget { onPressed: () async { if (isFullScreen) { await windowManager.setFullScreen(false); - await resizeWindow( - playerCore.videoParams?.aspect); + await resizeWindow(player.aspect); } else { await windowManager.setFullScreen(true); } @@ -141,8 +137,7 @@ class CustomAppBar extends HookWidget { onPressed: () async { if (isMaximized) { await windowManager.unmaximize(); - await resizeWindow( - playerCore.videoParams?.aspect); + await resizeWindow(player.aspect); } else { await windowManager.maximize(); } @@ -167,7 +162,7 @@ class CustomAppBar extends HookWidget { ), IconButton( onPressed: () async { - await playerCore.saveProgress(); + await player.saveProgress(); windowManager.close(); }, icon: const Icon(Icons.close_rounded), diff --git a/lib/widgets/dark_theme.dart b/lib/widgets/dark_theme.dart new file mode 100644 index 0000000..0f76539 --- /dev/null +++ b/lib/widgets/dark_theme.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:iris/theme.dart'; + +class DarkTheme extends HookWidget { + const DarkTheme({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = getTheme( + context: context, + lightDynamic: ThemeData.light().colorScheme, + darkDynamic: ThemeData.dark().colorScheme, + ); + + return Theme( + data: theme.dark.copyWith( + colorScheme: theme.dark.colorScheme.copyWith( + onSurfaceVariant: Colors.white.withValues(alpha: 0.95), + )), + child: child, + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 651df0d..92d50d3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_volume_controller_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterVolumeControllerPlugin"); flutter_volume_controller_plugin_register_with_registrar(flutter_volume_controller_registrar); + g_autoptr(FlPluginRegistrar) fvp_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FvpPlugin"); + fvp_plugin_register_with_registrar(fvp_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b956928..b3e9076 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_linux flutter_volume_controller + fvp gtk media_kit_libs_linux media_kit_video diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 23d8cf7..567a35b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import disks_desktop import dynamic_color import flutter_secure_storage_macos import flutter_volume_controller +import fvp import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -19,6 +20,7 @@ import path_provider_foundation import screen_brightness_macos import screen_retriever_macos import url_launcher_macos +import video_player_avfoundation import wakelock_plus import window_manager import window_size @@ -31,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterVolumeControllerPlugin.register(with: registry.registrar(forPlugin: "FlutterVolumeControllerPlugin")) + FvpPlugin.register(with: registry.registrar(forPlugin: "FvpPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) @@ -38,6 +41,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) diff --git a/pubspec.lock b/pubspec.lock index f8f268f..a99c607 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,6 +222,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -514,6 +522,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fvp: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "01c745657c72324f8da9941e56c61f3f35fc3bd8" + url: "https://github.com/wang-bin/fvp" + source: git + version: "0.29.0" glob: dependency: transitive description: @@ -546,6 +563,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + html: + dependency: transitive + description: + name: html + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + url: "https://pub.dev" + source: hosted + version: "0.15.5" http: dependency: "direct main" description: @@ -954,6 +979,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + popover: + dependency: "direct main" + description: + name: popover + sha256: "0606f3e10f92fc0459f5c52fd917738c29e7552323b28694d50c2d3312d0e1a2" + url: "https://pub.dev" + source: hosted + version: "0.3.1" posix: dependency: transitive description: @@ -1319,6 +1352,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" + url: "https://pub.dev" + source: hosted + version: "2.9.2" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "7018dbcb395e2bca0b9a898e73989e67c0c4a5db269528e1b036ca38bcca0d0b" + url: "https://pub.dev" + source: hosted + version: "2.7.17" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "8a4e73a3faf2b13512978a43cf1cdda66feeeb900a0527f1fbfd7b19cf3458d3" + url: "https://pub.dev" + source: hosted + version: "2.6.7" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "229d7642ccd9f3dc4aba169609dd6b5f3f443bb4cc15b82f7785fcada5af9bbb" + url: "https://pub.dev" + source: hosted + version: "6.2.3" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "881b375a934d8ebf868c7fb1423b2bfaa393a0a265fa3f733079a86536064a10" + url: "https://pub.dev" + source: hosted + version: "2.3.3" vm_service: dependency: transitive description: @@ -1336,7 +1409,7 @@ packages: source: hosted version: "2.0.8" wakelock_plus: - dependency: transitive + dependency: "direct main" description: name: wakelock_plus sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e" diff --git a/pubspec.yaml b/pubspec.yaml index 38ded35..6f2b246 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,40 +1,15 @@ name: iris description: "A lightweight video player" -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.2.1+3 +publish_to: 'none' +version: 1.3.0+3 environment: sdk: ^3.5.4 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - flutter_localizations: sdk: flutter intl: any @@ -72,61 +47,26 @@ dependencies: saf_util: ^0.6.2 screen_brightness: ^0.2.2 flutter_volume_controller: ^1.3.3 + # fvp: ^0.29.0 + fvp: + git: + url: https://github.com/wang-bin/fvp + ref: master + video_player: ^2.9.2 + wakelock_plus: ^1.2.10 + popover: ^0.3.1 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 flutter_oss_licenses: ^3.0.4 build_runner: ^2.4.14 freezed: ^2.5.8 json_serializable: ^6.9.3 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - generate: true - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: assets: - - assets/images/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + - assets/images/ \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a0be2f6..219a823 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterVolumeControllerPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterVolumeControllerPluginCApi")); + FvpPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FvpPluginCApi")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d6a51da..0c16c22 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_windows flutter_volume_controller + fvp media_kit_libs_windows_video media_kit_video permission_handler_windows