├── .fvm └── fvm_config.json ├── .gitignore ├── .metadata ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── feature-graphic.png │ │ ├── ic_launcher-web.png │ │ ├── kotlin │ │ │ └── jbscript │ │ │ │ └── remote_control_for_vlc │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-mdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable-xhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable │ │ │ ├── ic_launcher_background.xml │ │ │ ├── launch_background.xml │ │ │ ├── launch_icon.png │ │ │ └── normal_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ ├── ic_launcher_background.xml │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── cone.png ├── icon-512.png ├── linux-ifconfig.png ├── linux-lua.png ├── linux-main-interface.png ├── linux-menu.png ├── linux-password.png ├── linux-show-settings.png ├── linux-terminal.png ├── linux-web.png ├── mac-http-interface.png ├── mac-ip.png ├── mac-menu.png ├── mac-network.png ├── signal-0.png ├── signal-1.png ├── signal-2.png ├── signal-3.png ├── windows-cmd.png ├── windows-ipconfig.png ├── windows-lua.png ├── windows-main-interface.png ├── windows-menu.png ├── windows-password.png ├── windows-run.png ├── windows-show-settings.png └── windows-web.png ├── icons ├── google-play-icon.png ├── icon.png └── icon.svg ├── lib ├── equalizer_screen.dart ├── file_browser.dart ├── host_ip_guide.dart ├── main.dart ├── models.dart ├── open_media.dart ├── remote_control.dart ├── settings_screen.dart ├── utils.dart ├── vlc_configuration_guide.dart └── widgets.dart ├── pubspec.lock ├── pubspec.yaml └── screenshots ├── equalizer.png ├── file-browser.png ├── host-ip-guide-os.png ├── open-media.png ├── playback-speed.png ├── playing-menu-vlc.png ├── playing-vlc.png ├── settings.png ├── setup-guide-os.png ├── setup-guide-steps.png ├── setup.png └── vlc-connected.png /.fvm/fvm_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "flutterSdkVersion": "3.0.0", 3 | "flavors": {} 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .fvm/flutter_sdk 2 | demo-playlist.xspf 3 | 4 | # Miscellaneous 5 | *.class 6 | *.log 7 | *.pyc 8 | *.swp 9 | .DS_Store 10 | .atom/ 11 | .buildlog/ 12 | .history 13 | .svn/ 14 | migrate_working_dir/ 15 | 16 | # IntelliJ related 17 | *.iml 18 | *.ipr 19 | *.iws 20 | .idea/ 21 | 22 | # The .vscode folder contains launch configuration and tasks you configure in 23 | # VS Code which you may wish to be included in version control, so this line 24 | # is commented out by default. 25 | #.vscode/ 26 | 27 | # Flutter/Dart/Pub related 28 | **/doc/api/ 29 | **/ios/Flutter/.last_build_id 30 | .dart_tool/ 31 | .flutter-plugins 32 | .flutter-plugins-dependencies 33 | .packages 34 | .pub-cache/ 35 | .pub/ 36 | /build/ 37 | 38 | # Web related 39 | lib/generated_plugin_registrant.dart 40 | 41 | # Symbolication related 42 | app.*.symbols 43 | 44 | # Obfuscation related 45 | app.*.map.json 46 | 47 | # Android Studio will place build artifacts here 48 | /android/app/debug 49 | /android/app/profile 50 | /android/app/release 51 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 8 | channel: unknown 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remote Control for VLC 2 | 3 | A [VLC](https://www.videolan.org/vlc/) remote control written with [Flutter](https://flutter.io/). 4 | 5 | ## Initial Setup Guides 6 | 7 | The first time you start Remote Control for VLC, it will provide a guide to setting up VLC's web interface for remote control and will give you the option to try to discover the IP of the host VLC is running on automatically. 8 | 9 | The app's guides for initial VLC configuration and finding your host IP if it can't be found automatically are also available here if you need to reference them on a computer: 10 | 11 | ### [Remote Control for VLC Configuration Guides](https://insin.github.io/remote-control-for-vlc/) 12 | 13 | ## Demo 14 | 15 | [![Demo of Remote Control for VLC being used](https://img.youtube.com/vi/8eXJX4GVGhA/0.jpg)](https://www.youtube.com/watch?v=8eXJX4GVGhA) 16 | 17 | ## Screenshots 18 | 19 | ## Initial setup screens 20 | 21 | | Setup screen | Choose OS for setup guide | Setup guide | Connected | 22 | | ------------- | ------------------------- | ----------- | --------- | 23 | | [![](screenshots/setup.png)](screenshots/setup.png) | [![](screenshots/setup-guide-os.png)](screenshots/setup-guide-os.png) | [![](screenshots/setup-guide-steps.png)](screenshots/setup-guide-steps.png) | [![](screenshots/vlc-connected.png)](screenshots/vlc-connected.png) | 24 | 25 | ### Browsing for and playing media 26 | 27 | | Open media | File browser | Playlist | Playlist Menu | 28 | | ----------- | ------------ | -------- | ------------- | 29 | | [![](screenshots/open-media.png)](screenshots/open-media.png) | [![](screenshots/file-browser.png)](screenshots/file-browser.png) | [![](screenshots/playing-vlc.png)](screenshots/playing-vlc.png) | [![](screenshots/playing-menu-vlc.png)](screenshots/playing-menu-vlc.png) | 30 | 31 | ### Settings 32 | 33 | | Settings screen | Host IP guide | Equalizer | Playback Speed | 34 | | ---------------- | ------------- | --------- | -------------- | 35 | | [![](screenshots/settings.png)](screenshots/settings.png) | [![](screenshots/host-ip-guide-os.png)](screenshots/host-ip-guide-os.png) | [![](screenshots/equalizer.png)](screenshots/equalizer.png) | [![](screenshots/playback-speed.png)](screenshots/playback-speed.png) | 36 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | compileSdkVersion flutter.compileSdkVersion 30 | ndkVersion flutter.ndkVersion 31 | 32 | compileOptions { 33 | sourceCompatibility JavaVersion.VERSION_1_8 34 | targetCompatibility JavaVersion.VERSION_1_8 35 | } 36 | 37 | kotlinOptions { 38 | jvmTarget = '1.8' 39 | } 40 | 41 | sourceSets { 42 | main.java.srcDirs += 'src/main/kotlin' 43 | } 44 | 45 | defaultConfig { 46 | applicationId "jbscript.vlcremote" 47 | minSdkVersion flutter.minSdkVersion 48 | targetSdkVersion flutter.targetSdkVersion 49 | versionCode flutterVersionCode.toInteger() 50 | versionName flutterVersionName 51 | } 52 | 53 | signingConfigs { 54 | release { 55 | def keystorePropertiesFile = rootProject.file('key.properties') 56 | 57 | if (!keystorePropertiesFile.exists()) return; 58 | 59 | def keystoreProperties = new Properties() 60 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 61 | 62 | keyAlias keystoreProperties['keyAlias'] 63 | keyPassword keystoreProperties['keyPassword'] 64 | storeFile file(keystoreProperties['storeFile']) 65 | storePassword keystoreProperties['storePassword'] 66 | } 67 | } 68 | 69 | buildTypes { 70 | release { 71 | signingConfig signingConfigs.release 72 | } 73 | } 74 | } 75 | 76 | flutter { 77 | source '../..' 78 | } 79 | 80 | dependencies { 81 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 82 | } 83 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 9 | 17 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /android/app/src/main/feature-graphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/feature-graphic.png -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /android/app/src/main/kotlin/jbscript/remote_control_for_vlc/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package jbscript.remote_control_for_vlc 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/drawable/launch_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/normal_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.6.10' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.1.2' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /assets/cone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/cone.png -------------------------------------------------------------------------------- /assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/icon-512.png -------------------------------------------------------------------------------- /assets/linux-ifconfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-ifconfig.png -------------------------------------------------------------------------------- /assets/linux-lua.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-lua.png -------------------------------------------------------------------------------- /assets/linux-main-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-main-interface.png -------------------------------------------------------------------------------- /assets/linux-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-menu.png -------------------------------------------------------------------------------- /assets/linux-password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-password.png -------------------------------------------------------------------------------- /assets/linux-show-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-show-settings.png -------------------------------------------------------------------------------- /assets/linux-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-terminal.png -------------------------------------------------------------------------------- /assets/linux-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/linux-web.png -------------------------------------------------------------------------------- /assets/mac-http-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/mac-http-interface.png -------------------------------------------------------------------------------- /assets/mac-ip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/mac-ip.png -------------------------------------------------------------------------------- /assets/mac-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/mac-menu.png -------------------------------------------------------------------------------- /assets/mac-network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/mac-network.png -------------------------------------------------------------------------------- /assets/signal-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/signal-0.png -------------------------------------------------------------------------------- /assets/signal-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/signal-1.png -------------------------------------------------------------------------------- /assets/signal-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/signal-2.png -------------------------------------------------------------------------------- /assets/signal-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/signal-3.png -------------------------------------------------------------------------------- /assets/windows-cmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-cmd.png -------------------------------------------------------------------------------- /assets/windows-ipconfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-ipconfig.png -------------------------------------------------------------------------------- /assets/windows-lua.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-lua.png -------------------------------------------------------------------------------- /assets/windows-main-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-main-interface.png -------------------------------------------------------------------------------- /assets/windows-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-menu.png -------------------------------------------------------------------------------- /assets/windows-password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-password.png -------------------------------------------------------------------------------- /assets/windows-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-run.png -------------------------------------------------------------------------------- /assets/windows-show-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-show-settings.png -------------------------------------------------------------------------------- /assets/windows-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/assets/windows-web.png -------------------------------------------------------------------------------- /icons/google-play-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/icons/google-play-icon.png -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/icons/icon.png -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 26 | 30 | 34 | 35 | 37 | 41 | 45 | 46 | 53 | 60 | 70 | 72 | 76 | 80 | 81 | 90 | 92 | 96 | 100 | 101 | 110 | 112 | 116 | 120 | 124 | 128 | 129 | 138 | 140 | 144 | 148 | 152 | 153 | 162 | 164 | 168 | 172 | 176 | 177 | 186 | 188 | 192 | 196 | 200 | 201 | 211 | 213 | 217 | 221 | 225 | 226 | 235 | 237 | 241 | 245 | 246 | 248 | 252 | 256 | 257 | 267 | 277 | 280 | 285 | 286 | 296 | 305 | 314 | 323 | 332 | 341 | 350 | 359 | 368 | 369 | 389 | 392 | 393 | 395 | 396 | 398 | image/svg+xml 399 | 401 | 402 | 403 | 404 | 405 | 409 | 414 | 415 | 419 | 426 | 427 | 432 | 437 | 443 | 449 | 453 | 456 | 460 | 463 | 466 | 471 | 472 | 473 | 474 | 478 | 482 | 483 | 484 | 485 | 491 | 497 | 504 | 507 | 514 | 521 | 522 | 525 | 532 | 550 | 551 | 554 | 572 | 590 | 591 | 594 | 612 | 630 | 631 | 632 | 633 | -------------------------------------------------------------------------------- /lib/equalizer_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:math' as math; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:just_throttle_it/just_throttle_it.dart'; 6 | 7 | import 'models.dart'; 8 | import 'widgets.dart'; 9 | 10 | String _decibelsToString(double db) { 11 | var string = db.toStringAsFixed(1); 12 | if (string == '-0.0') { 13 | string = '0.0'; 14 | } 15 | return string; 16 | } 17 | 18 | var _frequencies = [ 19 | '60Hz', 20 | '170Hz', 21 | '310Hz', 22 | '600Hz', 23 | '1KHz', 24 | '3KHz', 25 | '6KHz', 26 | '12KHz', 27 | '14KHz', 28 | '16KHz' 29 | ]; 30 | 31 | class EqualizerScreen extends StatefulWidget { 32 | final Equalizer equalizer; 33 | final Stream equalizerStream; 34 | final Future Function(bool enabled) onToggleEnabled; 35 | final Future Function(int presetId) onPresetChange; 36 | final Future Function(String db) onPreampChange; 37 | final Future Function(int bandId, String db) onBandChange; 38 | 39 | const EqualizerScreen({ 40 | Key? key, 41 | required this.equalizer, 42 | required this.equalizerStream, 43 | required this.onToggleEnabled, 44 | required this.onPresetChange, 45 | required this.onPreampChange, 46 | required this.onBandChange, 47 | }) : super(key: key); 48 | 49 | @override 50 | State createState() => _EqualizerScreenState(); 51 | } 52 | 53 | class _EqualizerScreenState extends State { 54 | /// The most recent equalizer status from VLC. 55 | late Equalizer _equalizer = widget.equalizer; 56 | 57 | /// Used to listen for equalizer status updates from VLC. 58 | late final StreamSubscription _equalizerSubscription = 59 | widget.equalizerStream.listen(_onEqualizer); 60 | 61 | /// TODO Extract preset settings from VLC and use their preamp and band values to match a preset name 62 | /// The last-selected preset - selected preset info isn't available from VLC. 63 | Preset? _preset; 64 | 65 | /// The value for the Preamp slider during and after finishing dragging it. 66 | /// 67 | /// This will be removed once the preamp value from a VLC status update 68 | /// matches it. 69 | double? _preamp; 70 | 71 | /// Used to ignore equalizer status updates VLC after a band change finishes 72 | /// while sending requests to update equalizer bands. 73 | bool _ignoreStatusUpdates = false; 74 | 75 | /// When `true`, equalizer bands close to the band being adjusted will be 76 | /// adjusted in the same direction proportional to the amount the band has 77 | /// changed, falling off as proximity to the changing band decreases. 78 | bool _snapBands = true; 79 | 80 | /// The id/index of the band slider currently being dragged. 81 | int? _draggingBand; 82 | 83 | /// The initial equalizer state when a band slider started dragging.- used to 84 | /// calculate values for other bands when [_snapBands] is `true. 85 | /// 86 | /// To prevent band values jumping to their former values after a drag 87 | /// finishes, values for modified bands are stored in this object, which is 88 | /// used for display until equalizer band values from VLC status updates match 89 | /// its values. 90 | Equalizer? _draggingEqualizer; 91 | 92 | /// The value [_draggingBand] had when its drag started 93 | /// 94 | /// Used with [_dragValue] to calculate the delta when snapping bands. 95 | double? _dragStartValue; 96 | 97 | /// The current value of the band being dragged. 98 | double? _dragValue; 99 | 100 | @override 101 | void dispose() { 102 | _equalizerSubscription.cancel(); 103 | super.dispose(); 104 | } 105 | 106 | _onEqualizer(Equalizer? equalizer) { 107 | if (equalizer == null) { 108 | Navigator.pop(context); 109 | return; 110 | } 111 | if (_ignoreStatusUpdates) { 112 | return; 113 | } 114 | setState(() { 115 | // Get rid of the equalizer containing values from a finished EQ change 116 | // once the equalizer from VLC status updates matches it. 117 | if (_draggingBand == null && 118 | _draggingEqualizer != null && 119 | _draggingEqualizer!.bands.every((band) => 120 | _decibelsToString(band.value) == 121 | _decibelsToString(equalizer.bands[band.id].value))) { 122 | _draggingEqualizer = null; 123 | } 124 | _equalizer = equalizer; 125 | }); 126 | } 127 | 128 | _toggleEnabled(enabled) async { 129 | Equalizer? equalizer = await widget.onToggleEnabled(enabled); 130 | if (equalizer == null) { 131 | if (mounted) { 132 | Navigator.pop(context); 133 | } 134 | return; 135 | } 136 | setState(() { 137 | _equalizer = equalizer; 138 | }); 139 | } 140 | 141 | _choosePreset() async { 142 | var preset = await showDialog( 143 | context: context, 144 | builder: (context) => SimpleDialog( 145 | title: const Text('Preset'), 146 | children: _equalizer.presets 147 | .map((preset) => SimpleDialogOption( 148 | child: Text(preset.name), 149 | onPressed: () { 150 | Navigator.pop(context, preset); 151 | }, 152 | )) 153 | .toList(), 154 | ), 155 | ); 156 | if (preset == null) { 157 | return; 158 | } 159 | setState(() { 160 | _preset = preset; 161 | }); 162 | var equalizer = await widget.onPresetChange(preset.id); 163 | if (equalizer == null) { 164 | if (mounted) { 165 | Navigator.pop(context); 166 | } 167 | return; 168 | } 169 | setState(() { 170 | _equalizer = equalizer; 171 | }); 172 | } 173 | 174 | _onPreampChanged(preamp) async { 175 | var equalizer = await widget.onPreampChange(_decibelsToString(preamp)); 176 | if (equalizer == null) { 177 | if (mounted) { 178 | Navigator.pop(context); 179 | } 180 | return; 181 | } 182 | setState(() { 183 | _equalizer = equalizer; 184 | _preamp = null; 185 | }); 186 | } 187 | 188 | double _getBandValue(int band) { 189 | // Not dragging, use the current value 190 | // If we finished changing a band, use the new values until they're current 191 | if (_draggingBand == null) { 192 | return (_draggingEqualizer ?? _equalizer).bands[band].value; 193 | } 194 | // The dragging band always uses the drag value 195 | if (band == _draggingBand) { 196 | return _dragValue!; 197 | } 198 | // If we're not snapping, other bands use the current value 199 | if (!_snapBands) { 200 | return _equalizer.bands[band].value; 201 | } 202 | // Otherwise add portions of the size of the change to neighbouring bands 203 | var distance = (band - _draggingBand!).abs(); 204 | switch (distance) { 205 | case 1: 206 | return (_draggingEqualizer!.bands[band].value + 207 | ((_dragValue! - _dragStartValue!) / 2)) 208 | .clamp(-20.0, 20.0); 209 | case 2: 210 | return (_draggingEqualizer!.bands[band].value + 211 | ((_dragValue! - _dragStartValue!) / 8)) 212 | .clamp(-20.0, 20.0); 213 | case 3: 214 | return (_draggingEqualizer!.bands[band].value + 215 | ((_dragValue! - _dragStartValue!) / 40)) 216 | .clamp(-20.0, 20.0); 217 | default: 218 | return _equalizer.bands[band].value; 219 | } 220 | } 221 | 222 | _onBandChangeStart(int band, double value) { 223 | setState(() { 224 | _draggingEqualizer = _equalizer; 225 | _draggingBand = band; 226 | _dragStartValue = value; 227 | _dragValue = value; 228 | }); 229 | } 230 | 231 | _onBandChanged(double value) { 232 | setState(() { 233 | _dragValue = value; 234 | }); 235 | } 236 | 237 | _onBandChangeEnd(double value) async { 238 | List bandChanges = []; 239 | if (!_snapBands) { 240 | _draggingEqualizer!.bands[_draggingBand!].value = value; 241 | bandChanges.add(Band(_draggingBand!, value)); 242 | } else { 243 | for (int band = math.max(0, _draggingBand! - 3); 244 | band < math.min(_frequencies.length, _draggingBand! + 4); 245 | band++) { 246 | var value = _getBandValue(band); 247 | // Store new values to display while VLC status catches up 248 | _draggingEqualizer!.bands[band].value = value; 249 | bandChanges.add(Band(band, value)); 250 | } 251 | } 252 | setState(() { 253 | _draggingBand = null; 254 | _dragStartValue = null; 255 | _dragValue = null; 256 | }); 257 | _ignoreStatusUpdates = true; 258 | await Future.wait(bandChanges.map( 259 | (band) => widget.onBandChange(band.id, _decibelsToString(band.value)))); 260 | _ignoreStatusUpdates = false; 261 | } 262 | 263 | @override 264 | Widget build(BuildContext context) { 265 | var theme = Theme.of(context); 266 | return Scaffold( 267 | appBar: AppBar( 268 | title: Text(intl('Equalizer')), 269 | ), 270 | body: ListView(children: [ 271 | SwitchListTile( 272 | title: const Text('Enable', textAlign: TextAlign.right), 273 | value: _equalizer.enabled, 274 | onChanged: _toggleEnabled, 275 | ), 276 | if (_equalizer.enabled) 277 | Column(children: [ 278 | ListTile( 279 | title: const Text('Preset'), 280 | subtitle: Text(_preset?.name ?? 'Tap to select'), 281 | onTap: _choosePreset, 282 | ), 283 | Padding( 284 | padding: const EdgeInsets.symmetric(horizontal: 16), 285 | child: Row( 286 | children: [ 287 | Text('Preamp', style: theme.textTheme.subtitle1), 288 | const SizedBox(width: 16), 289 | Expanded( 290 | child: SliderTheme( 291 | data: SliderTheme.of(context).copyWith( 292 | trackShape: FullWidthTrackShape(), 293 | ), 294 | child: Slider( 295 | max: 20, 296 | min: -20, 297 | value: _preamp ?? _equalizer.preamp, 298 | onChanged: (db) { 299 | setState(() { 300 | _preamp = db; 301 | }); 302 | Throttle.milliseconds(333, widget.onPreampChange, 303 | [_decibelsToString(db)]); 304 | }, 305 | onChangeEnd: _onPreampChanged, 306 | ), 307 | ), 308 | ) 309 | ], 310 | ), 311 | ), 312 | const SizedBox(height: 16), 313 | Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ 314 | for (int i = 0; i < _frequencies.length; i++) 315 | _VerticalBandSlider( 316 | label: _frequencies[i], 317 | band: i, 318 | value: _getBandValue(i), 319 | onChangeStart: _onBandChangeStart, 320 | onChanged: _onBandChanged, 321 | onChangeEnd: _onBandChangeEnd, 322 | ), 323 | ]), 324 | SwitchListTile( 325 | title: const Text('Snap bands', textAlign: TextAlign.right), 326 | value: _snapBands, 327 | onChanged: (snapBands) { 328 | setState(() { 329 | _snapBands = snapBands; 330 | }); 331 | }, 332 | ), 333 | ]), 334 | ]), 335 | ); 336 | } 337 | } 338 | 339 | class _VerticalBandSlider extends StatefulWidget { 340 | final String label; 341 | final int band; 342 | final double value; 343 | final Function(int band, double value) onChangeStart; 344 | final Function(double value) onChanged; 345 | final Function(double value) onChangeEnd; 346 | 347 | const _VerticalBandSlider({ 348 | required this.label, 349 | required this.band, 350 | required this.value, 351 | required this.onChangeStart, 352 | required this.onChanged, 353 | required this.onChangeEnd, 354 | }); 355 | 356 | @override 357 | _VerticalBandSliderState createState() => _VerticalBandSliderState(); 358 | } 359 | 360 | class _VerticalBandSliderState extends State<_VerticalBandSlider> { 361 | @override 362 | Widget build(BuildContext context) { 363 | return Column(children: [ 364 | const Text('+20dB', style: TextStyle(fontSize: 10)), 365 | const SizedBox(height: 16), 366 | RotatedBox( 367 | quarterTurns: -1, 368 | child: SliderTheme( 369 | data: SliderTheme.of(context).copyWith( 370 | trackShape: FullWidthTrackShape(), 371 | thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), 372 | overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), 373 | ), 374 | child: Slider( 375 | max: 20, 376 | min: -20, 377 | value: widget.value, 378 | onChangeStart: (value) { 379 | widget.onChangeStart(widget.band, value); 380 | }, 381 | onChanged: (value) { 382 | widget.onChanged(value); 383 | }, 384 | onChangeEnd: (value) { 385 | widget.onChangeEnd(value); 386 | }, 387 | ), 388 | ), 389 | ), 390 | const SizedBox(height: 16), 391 | const Text('-20dB', style: TextStyle(fontSize: 10)), 392 | const SizedBox(height: 8), 393 | Text(widget.label, 394 | style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold)), 395 | ]); 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /lib/file_browser.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:xml/xml.dart' as xml; 6 | 7 | import 'models.dart'; 8 | import 'widgets.dart'; 9 | 10 | class FileBrowser extends StatefulWidget { 11 | final BrowseItem dir; 12 | final bool Function(BrowseItem) isFave; 13 | final Function(BrowseItem) onToggleFave; 14 | final Settings settings; 15 | 16 | const FileBrowser({ 17 | Key? key, 18 | required this.dir, 19 | required this.isFave, 20 | required this.onToggleFave, 21 | required this.settings, 22 | }) : super(key: key); 23 | 24 | @override 25 | State createState() => _FileBrowserState(); 26 | } 27 | 28 | class _FileBrowserState extends State { 29 | bool _loading = false; 30 | String? _errorMessage; 31 | String? _errorDetail; 32 | List _items = []; 33 | 34 | @override 35 | void initState() { 36 | _getListing(widget.dir); 37 | super.initState(); 38 | } 39 | 40 | _getListing(BrowseItem dir) async { 41 | setState(() { 42 | _loading = true; 43 | }); 44 | 45 | http.Response response; 46 | 47 | assert(() { 48 | //print('/requests/browse.xml?uri=${dir.uri}'); 49 | return true; 50 | }()); 51 | 52 | try { 53 | response = await http.get( 54 | Uri.http(widget.settings.connection.authority, '/requests/browse.xml', { 55 | 'uri': dir.uri, 56 | }), 57 | headers: { 58 | 'Authorization': 59 | 'Basic ${base64Encode(utf8.encode(':${widget.settings.connection.password}'))}', 60 | }, 61 | ).timeout(const Duration(seconds: 2)); 62 | } catch (e) { 63 | setState(() { 64 | _errorMessage = 'Error connecting to VLC'; 65 | _errorDetail = e.runtimeType.toString(); 66 | _loading = false; 67 | }); 68 | return; 69 | } 70 | 71 | List dirs = []; 72 | List files = []; 73 | 74 | if (response.statusCode == 200) { 75 | var document = xml.XmlDocument.parse(utf8.decode(response.bodyBytes)); 76 | document.findAllElements('element').forEach((el) { 77 | var item = BrowseItem( 78 | el.getAttribute('type') ?? '', 79 | el.getAttribute('name') ?? '', 80 | el.getAttribute('path') ?? '', 81 | el.getAttribute('uri') ?? '', 82 | ); 83 | 84 | if (item.name == '..') { 85 | return; 86 | } 87 | 88 | if (item.isDir) { 89 | dirs.add(item); 90 | } else if (item.isSupportedMedia || item.isPlaylist) { 91 | files.add(item); 92 | } 93 | }); 94 | } 95 | 96 | dirs.sort((a, b) { 97 | return a.name.toLowerCase().compareTo(b.name.toLowerCase()); 98 | }); 99 | files.sort((a, b) { 100 | return a.name.toLowerCase().compareTo(b.name.toLowerCase()); 101 | }); 102 | 103 | setState(() { 104 | _items = dirs + files; 105 | _loading = false; 106 | }); 107 | } 108 | 109 | _handleTap(BrowseItem item) async { 110 | if (item.isDir) { 111 | BrowseResult? result = await Navigator.push( 112 | context, 113 | MaterialPageRoute( 114 | builder: (context) => FileBrowser( 115 | dir: item, 116 | isFave: widget.isFave, 117 | onToggleFave: widget.onToggleFave, 118 | settings: widget.settings, 119 | ), 120 | ), 121 | ); 122 | if (result != null) { 123 | if (mounted) { 124 | Navigator.pop(context, result); 125 | } 126 | } 127 | } else { 128 | Navigator.pop(context, BrowseResult(item, BrowseResultIntent.play)); 129 | } 130 | } 131 | 132 | @override 133 | Widget build(BuildContext context) { 134 | return Scaffold( 135 | appBar: AppBar( 136 | title: Text(widget.dir.title), 137 | actions: widget.dir.path != '' 138 | ? [ 139 | IconButton( 140 | onPressed: () { 141 | setState(() { 142 | widget.onToggleFave(widget.dir); 143 | }); 144 | }, 145 | icon: Icon( 146 | widget.isFave(widget.dir) ? Icons.star : Icons.star_border, 147 | color: Colors.white, 148 | ), 149 | ) 150 | ] 151 | : null, 152 | ), 153 | body: _renderList(), 154 | ); 155 | } 156 | 157 | _renderList() { 158 | if (_loading) { 159 | return Center( 160 | child: Column( 161 | mainAxisAlignment: MainAxisAlignment.center, 162 | children: const [CircularProgressIndicator()], 163 | ), 164 | ); 165 | } 166 | 167 | if (_errorMessage != null) { 168 | return Center( 169 | child: Column( 170 | mainAxisAlignment: MainAxisAlignment.center, 171 | children: [ 172 | ListTile( 173 | leading: const Icon( 174 | Icons.error, 175 | color: Colors.redAccent, 176 | size: 48, 177 | ), 178 | title: Text(_errorMessage!), 179 | subtitle: Text(_errorDetail!), 180 | ), 181 | ], 182 | ), 183 | ); 184 | } 185 | 186 | if (_items.isEmpty) { 187 | return Center( 188 | child: Column( 189 | mainAxisAlignment: MainAxisAlignment.center, 190 | children: const [Text('No compatible files found')], 191 | ), 192 | ); 193 | } 194 | 195 | return Scrollbar( 196 | child: ListView.separated( 197 | itemCount: _items.length, 198 | itemBuilder: (context, i) { 199 | var item = _items[i]; 200 | return EnqueueMenuGestureDetector( 201 | item: item, 202 | child: ListTile( 203 | dense: widget.settings.dense, 204 | leading: Icon(item.icon), 205 | title: Text(item.title), 206 | enabled: !_loading, 207 | onTap: () { 208 | _handleTap(item); 209 | }, 210 | ), 211 | ); 212 | }, 213 | separatorBuilder: (context, i) { 214 | if (_items[i].isDir && 215 | i < _items.length - 1 && 216 | _items[i + 1].isFile) { 217 | return const Divider(); 218 | } 219 | return const SizedBox.shrink(); 220 | }, 221 | ), 222 | ); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /lib/host_ip_guide.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'models.dart'; 4 | import 'widgets.dart'; 5 | 6 | class HostIpGuide extends StatefulWidget { 7 | const HostIpGuide({Key? key}) : super(key: key); 8 | 9 | @override 10 | State createState() => _HostIpGuideState(); 11 | } 12 | 13 | class _HostIpGuideState extends State { 14 | OperatingSystem? _os; 15 | 16 | _onOsChanged(os) { 17 | setState(() { 18 | _os = os; 19 | }); 20 | } 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Scaffold( 25 | appBar: AppBar( 26 | centerTitle: true, 27 | title: Column( 28 | crossAxisAlignment: CrossAxisAlignment.end, 29 | children: [ 30 | const Text('Host IP Guide'), 31 | if (_os != null) 32 | Text('for ${osNames[_os]}', style: const TextStyle(fontSize: 13)), 33 | ], 34 | ), 35 | ), 36 | body: buildBody(), 37 | ); 38 | } 39 | 40 | Widget buildBody() { 41 | var theme = Theme.of(context); 42 | switch (_os) { 43 | case OperatingSystem.macos: 44 | return ListView(padding: const EdgeInsets.all(24), children: [ 45 | TextAndImages(children: [ 46 | const Text('From the Apple menu, select "System Preferences".'), 47 | const Text( 48 | 'In the System Preferences window, click on the "Network" item:'), 49 | Image.asset('assets/mac-network.png'), 50 | const Text( 51 | 'Your IP address will be visible to the right of the Network window, as shown below:'), 52 | Image.asset('assets/mac-ip.png'), 53 | ]), 54 | ]); 55 | case OperatingSystem.linux: 56 | return ListView(padding: const EdgeInsets.all(24), children: [ 57 | TextAndImages(children: [ 58 | const Text( 59 | 'Open a Terminal – on many popular distros this can be done by pressing Ctr + Alt + T.'), 60 | const Text( 61 | 'In the Terminal window, type "ifconfig" and press Enter:'), 62 | Image.asset('assets/linux-terminal.png'), 63 | const Text( 64 | 'Your host IP will be one of the "inet" IP results which appears in the output of the command:'), 65 | Image.asset('assets/linux-ifconfig.png'), 66 | const Text( 67 | 'Depending on how your computer is set up, there may be multiple results:'), 68 | const Text( 69 | 'If you connect to the network via an ethernet cable, look for the inet IP under an "eth0" interface.'), 70 | const Text( 71 | 'If you connect to the network via Wi-Fi, look for the inet IP under a "wlan0" interface.'), 72 | ]), 73 | ]); 74 | case OperatingSystem.windows: 75 | return ListView(padding: const EdgeInsets.all(24), children: [ 76 | TextAndImages(children: [ 77 | const Text( 78 | 'Open a Command Prompt by pressing Win + R, typing "cmd" in the dialog which appears, and presssing Enter:'), 79 | Image.asset('assets/windows-run.png'), 80 | const Text( 81 | 'In the Command Prompt window, type "ipconfig" and press Enter:'), 82 | Image.asset('assets/windows-cmd.png'), 83 | const Text( 84 | 'Your host IP will be one of the "IPv4 Address" results which appears in the output of the command:'), 85 | Image.asset('assets/windows-ipconfig.png'), 86 | const Text( 87 | 'Depending on how your computer is set up, there may be multiple results:'), 88 | const Text( 89 | 'If you connect to the network via an ethernet cable, look for the IPv4 Address under an "Ethernet adapter Ethernet" result.'), 90 | const Text( 91 | 'If you connect to the network via Wi-Fi, look for the IPv4 Address under an "Ethernet adapter Wireless Network Connection" result.'), 92 | ]), 93 | ]); 94 | default: 95 | return Column( 96 | children: [ 97 | Padding( 98 | padding: const EdgeInsets.all(24), 99 | child: Wrap( 100 | runSpacing: 16, 101 | children: [ 102 | Text('Which operating system are you running VLC on?', 103 | style: theme.textTheme.subtitle1), 104 | Column( 105 | children: OperatingSystem.values 106 | .map((os) => RadioListTile( 107 | title: Text(osNames[os]!), 108 | value: os, 109 | groupValue: _os, 110 | onChanged: _onOsChanged, 111 | )) 112 | .toList()) 113 | ], 114 | ), 115 | ), 116 | ], 117 | ); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:shared_preferences/shared_preferences.dart'; 6 | 7 | import 'models.dart'; 8 | import 'remote_control.dart'; 9 | 10 | /// Global access to the navigator state for showing an error dialog. 11 | final GlobalKey navigatorKey = 12 | GlobalKey(debugLabel: 'AppNavigator'); 13 | 14 | /// Flag to avoid showing multiple error dialogs. 15 | var showingErrorDialog = false; 16 | 17 | void main() async { 18 | FlutterError.onError = (FlutterErrorDetails details) async { 19 | Zone.current.handleUncaughtError(details.exception, details.stack!); 20 | }; 21 | 22 | runZonedGuarded>(() async { 23 | WidgetsFlutterBinding.ensureInitialized(); 24 | var prefs = await SharedPreferences.getInstance(); 25 | runApp(VlcRemote(prefs: prefs, settings: Settings(prefs))); 26 | }, (error, stackTrace) async { 27 | if (showingErrorDialog || 28 | navigatorKey.currentState?.overlay?.context == null) { 29 | return; 30 | } 31 | showingErrorDialog = true; 32 | await showDialog( 33 | context: navigatorKey.currentState!.overlay!.context, 34 | builder: (context) => AlertDialog( 35 | title: const Text('Unhandled Error'), 36 | content: SingleChildScrollView( 37 | child: Column( 38 | children: [ 39 | Text('$error\n\n$stackTrace'), 40 | ], 41 | ), 42 | ), 43 | actions: [ 44 | TextButton( 45 | child: const Text('COPY ERROR DETAILS'), 46 | onPressed: () { 47 | Clipboard.setData( 48 | ClipboardData(text: '$error\n\n$stackTrace'), 49 | ); 50 | }, 51 | ) 52 | ], 53 | ), 54 | ); 55 | showingErrorDialog = false; 56 | }); 57 | } 58 | 59 | class VlcRemote extends StatelessWidget { 60 | final SharedPreferences prefs; 61 | final Settings settings; 62 | 63 | const VlcRemote({ 64 | Key? key, 65 | required this.prefs, 66 | required this.settings, 67 | }) : super(key: key); 68 | 69 | @override 70 | Widget build(BuildContext context) { 71 | return MaterialApp( 72 | navigatorKey: navigatorKey, 73 | title: 'Remote Control for VLC', 74 | theme: ThemeData( 75 | primarySwatch: Colors.deepOrange, 76 | ), 77 | home: RemoteControl(prefs: prefs, settings: settings), 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/models.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:shared_preferences/shared_preferences.dart'; 5 | import 'package:xml/xml.dart' as xml; 6 | 7 | import 'utils.dart'; 8 | 9 | var _videoExtensions = RegExp( 10 | r'\.(3g2|3gp|3gp2|3gpp|amv|asf|avi|divx|drc|dv|f4v|flv|gvi|gxf|ismv|iso|m1v|m2v|m2t|m2ts|m4v|mkv|mov|mp2|mp2v|mp4|mp4v|mpe|mpeg|mpeg1|mpeg2|mpeg4|mpg|mpv2|mts|mtv|mxf|mxg|nsv|nut|nuv|ogm|ogv|ogx|ps|rec|rm|rmvb|tod|ts|tts|vob|vro|webm|wm|wmv|wtv|xesc)$'); 11 | 12 | var _audioExtensions = RegExp( 13 | r'\.(3ga|a52|aac|ac3|adt|adts|aif|aifc|aiff|alac|amr|aob|ape|awb|caf|dts|flac|it|m4a|m4b|m4p|mid|mka|mlp|mod|mpa|mp1|mp2|mp3|mpc|mpga|oga|ogg|oma|opus|ra|ram|rmi|s3m|spx|tta|voc|vqf|w64|wav|wma|wv|xa|xm)$'); 14 | 15 | var _playlistExtensions = RegExp( 16 | r'\.(asx|b4s|cue|ifo|m3u|m3u8|pls|ram|rar|sdp|vlc|xspf|wax|wvx|zip|conf)'); 17 | 18 | var _audioTranslations = RegExp( 19 | r"^(Audio|_Audio|Ameslaw|Aodio|Audioa|Audiu|Deng|Dźwięk|Ekirikuhurirwa|Endobozi|Fuaim|Fuaim|Garsas|Hang|Hljóð|Leo|Ljud|Lyd|M_adungan|Ma giwinyo|Odio|Ojoo|Oudio|Ovoz|Sain|Ses|Sonido|Səs|Umsindo|Zvok|Zvuk|Zëri|Àudio|Áudio|Ääni|Ήχος|Аудио|Аўдыё|Дуу|Дыбыс|Звук|Ձայն|שמע|آڈیو, صدا|ئۈن|آڈیو|دەنگ|صدا|غږيز|अडिअ'|अडियो|आडियो|ध्वनी|অডিঅ'|অডিও|ਆਡੀਓ|ઓડિયો|ଅଡ଼ିଓ|ஒலி|శ్రవ్యకం|ಧ್ವನಿ|ഓഡിയോ|ශ්‍රව්‍ය|เสียง|အသံ|აუდიო|ተሰሚ|ድምፅ|អូឌីយ៉ូ|オーディオ|音訊|音频|오디오)$"); 20 | 21 | var _codecTranslations = RegExp( 22 | r"^(Codec|Bonez|Codifica|Codificador|Cudecu|Còdec|Códec|Dekko|Enkusike|i-Codec|Kodavimas|Kodek|Kodeka|Kodeks|Kodlayıcı/Çözücü|Koodek|Koodekki|Kóðalykill (codec)|Kôdek|Scéim Comhbhrúite|Кодек|Кодэк|Կոդեկ|מקודד/מפענח|كود يەشكۈچ|كوديك|کوڈیک|کوډېک|کُدک|کۆدێک|कोडेक|কোডেক|કોડેક|କୋଡେକ୍|கோடக்|కొడెక్|ಸಂಕೇತಕ|കോഡെക്ക്|කොඩෙක්|ตัวอ่าน-ลงรหัส|კოდეკი|ኮዴክ|កូដិក|コーデック|編解碼器|编解码器|코덱)$"); 23 | 24 | var _descriptionTranslations = RegExp( 25 | r"^(Description|Apraksts|Aprašymas|Açıklama|Beschreibung|Beschrijving|Beskriuwing|Beskrivelse|Beskrivning|Beskrywing|Cifagol|Cur síos|Descrición|Descriere|Descripcion|Descripció|Descripción|Descrizion|Descrizione|Descrição|Deskribapena|Deskripsi|Deskrivadur|Discrijhaedje|Discrizzione|Disgrifiad|Ennyinyonyola|Enshoborora|Fa'amatalaga|Hedef|Incazelo|Keterangan|Kirjeldus|Kuvaus|Leírás|Lýsing|Mô tả|Opis|Popis|Përshkrimi|Skildring|Ta’rifi|Te lok|Tuairisgeul|Περιγραφή|Апісанне|Баяндама|Опис|Описание|Сипаттама|Тайлбар|Тасвирлама|Նկարագրություն|תיאור|الوصف|سپړاوی|شرح|وضاحت|پەسن|چۈشەندۈرۈش|बेखेवथि|वर्णन|विवरण|বর্ণনা|বিবরণ|বিৱৰণ|ਵੇਰਵਾ|વર્ણન|ବିବରଣୀ|விவரம்|వివరణ|ವಿವರಣೆ|വിവരണം|විස්තරය|รายละเอียด|ဖော်ပြချက်|აღწერილობა|መግለጫ|សេចក្ដី​ពណ៌នា|描述|說明|説明|설명)$"); 26 | 27 | var _languageTranslations = RegExp( 28 | r"^(Language|Bahasa|Bahasa|Cànan|Dil|Gagana|Gjuha|Hizkuntza|Iaith|Idioma|Jazyk|Jezik|Kalba|Keel|Kieli|Langue|Leb|Lenga|Lenghe|Limbă|Lingaedje|Lingua|Llingua|Ngôn ngữ|Nyelv|Olulimi|Orurimi|Sprache|Språk|Taal|Teanga|Til|Tungumál|Ulimi|Valoda|Wybór języka|Yezh|Ziman|Ɗemngal|Γλώσσα|Език|Мова|Тел|Тил|Тілі|Хэл|Язык|Језик|Լեզու|שפה|اللغة|تىل|زبان|زمان|ژبه|भाषा|राव|ভাষা|ਭਾਸ਼ਾ|ભાષા|ଭାଷା|மொழி|భాష|ಭಾಷೆ|ഭാഷ|භාෂාව|ภาษา|ဘာသာစကား|ენა|ቋንቋ|ቋንቋ|ភាសា|言語|語言|语言|언어)$"); 29 | 30 | var _subtitleTranslations = RegExp( 31 | r"^(Subtitle|Altyazı|Azpititulua|Binnivîs/OSD|Emitwe|Felirat|Fo-thiotal|Fotheideal|Gagana fa'aliliu|Isdeitlau|Istitl|Izihlokwana|Legenda|Legendas|Lestiitol|Napisy|Omutwe ogwokubiri|Onderskrif|Ondertitel|Phụ đề|Podnapisi|Podnaslov|Podtitl|Sarikata|Sortite|Sostítols|Sot titul|Sottotitoli|Sottutitulu|Sous-titres|Subtiiter|Subtitlu|Subtitol|Subtitr|Subtitrai|Subtitrs|Subtitulo|Subtítol|Subtítulo|Subtítulos|Subtítulu|Tekstitys|Terjemahan|Texti|Titra|Titulky|Titulky|Undertekst|Undertext|Undertitel|Υπότιτλος|Дэд бичвэр|Превод|Субтитр|Субтитри|Субтитрлер|Субтитры|Субтитрі|Субтытры|Титл|Ենթագիր|अनुवाद पट्टी|उपशीर्षक|दालाय-बिमुं|উপশিৰোনাম|বিকল্প নাম|সাবটাইটেল|ਸਬ-ਟਾਈਟਲ|ઉપશીર્ષક|ଉପଟାଇଟେଲ୍‌|துணை உரை|ఉపశీర్షిక|ಉಪಶೀರ್ಷಿಕೆ|ഉപശീര്‍ഷകം|උපසිරැසි|บทบรรยาย|စာတန်းထိုး|ტიტრები|ንዑስ አርእስት|ጽሁፋዊ ትርጉሞች|ចំណង​ជើង​រង|字幕|자막)$"); 32 | 33 | var _typeTranslations = RegExp( 34 | r"^(Type|Cineál|Cure|Ekyika|Fannu|Handiika|Itū'āiga|Jenis|Kite|Liik|Loại|Math|Mota|Rizh|Seòrsa|Sôre|Tegund|Tip|Tipas|Tipe|Tipi|Tipo|Tips|Tipu|Tipus|Turi|Typ|Typo|Tyyppi|Típus|Tür|Uhlobo|Vrsta|Τύπος|Врста|Тип|Түрі|Түрү|Төр|Төрөл|Տեսակ|סוג|تىپى|جۆر|نوع|ٹایِپ|ډول|टंकलेखन करा|टाइप|प्रकार|रोखोम|ধরন|প্রকার|প্ৰকাৰ|ਟਾਈਪ|પ્રકાર|ପ୍ରକାର|வகை|రకం|ಪ್ರಕಾರ|തരം|වර්ගය|ประเภท|အမျိုးအစား|ტიპი|አይነት|ប្រភេទ|タイプ|类型|類型|형식)$"); 35 | 36 | enum OperatingSystem { linux, macos, windows } 37 | 38 | Map osNames = { 39 | OperatingSystem.linux: 'Linux', 40 | OperatingSystem.macos: 'macOS', 41 | OperatingSystem.windows: 'Windows', 42 | }; 43 | 44 | class BrowseItem { 45 | String type, name, path, uri; 46 | 47 | BrowseItem( 48 | this.type, 49 | this.name, 50 | this.path, 51 | this.uri, 52 | ); 53 | 54 | BrowseItem.fromUrl(String url) 55 | : uri = url.startsWith(urlRegExp) ? url : 'https://$url', 56 | type = 'web', 57 | path = '', 58 | name = ''; 59 | 60 | /// Sending a directory: url when enqueueing makes a directory display as directory in the VLC 61 | /// playlist instead of as a generic file. 62 | String get playlistUri { 63 | if (isDir) return uri.replaceAll(RegExp(r'^file'), 'directory'); 64 | return uri; 65 | } 66 | 67 | IconData get icon { 68 | if (isDir) { 69 | return Icons.folder; 70 | } 71 | if (isWeb) { 72 | return Icons.public; 73 | } 74 | if (isAudio) { 75 | return Icons.audiotrack; 76 | } 77 | if (isVideo) { 78 | return Icons.movie; 79 | } 80 | if (isPlaylist) { 81 | return Icons.list; 82 | } 83 | return Icons.insert_drive_file; 84 | } 85 | 86 | bool get isAudio => _audioExtensions.hasMatch(path); 87 | 88 | bool get isDir => type == 'dir'; 89 | 90 | bool get isFile => type == 'file'; 91 | 92 | bool get isPlaylist => _playlistExtensions.hasMatch(path); 93 | 94 | bool get isSupportedMedia => isAudio || isVideo || isWeb; 95 | 96 | bool get isVideo => _videoExtensions.hasMatch(path); 97 | 98 | bool get isWeb => type == 'web'; 99 | 100 | String get title => isVideo ? cleanVideoTitle(name, keepExt: isFile) : name; 101 | 102 | BrowseItem.fromJson(Map json) 103 | : type = json['type'], 104 | name = json['name'], 105 | path = json['path'], 106 | uri = json['uri']; 107 | 108 | Map toJson() => { 109 | 'type': type, 110 | 'name': name, 111 | 'path': path, 112 | 'uri': uri, 113 | }; 114 | 115 | @override 116 | String toString() => 'BrowseItem(${toJson()})'; 117 | } 118 | 119 | enum BrowseResultIntent { play, enqueue } 120 | 121 | class BrowseResult { 122 | BrowseItem item; 123 | BrowseResultIntent intent; 124 | BrowseResult(this.item, this.intent); 125 | } 126 | 127 | // ignore: unused_element 128 | const _emulatorLocalhost = '10.0.2.2'; 129 | 130 | const defaultPort = '8080'; 131 | const defaultPassword = 'vlcplayer'; 132 | 133 | var _ipPattern = RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'); 134 | var _numericPattern = RegExp(r'^\d+$'); 135 | 136 | class Connection { 137 | late String _ip = ''; 138 | late String _port = defaultPort; 139 | late String _password = defaultPassword; 140 | 141 | String? _ipError; 142 | String? _portError; 143 | String? _passwordError; 144 | 145 | Connection(); 146 | 147 | String get ip => _ip; 148 | String get port => _port; 149 | String get password => _password; 150 | get ipError => _ipError; 151 | get portError => _portError; 152 | get passwordError => _passwordError; 153 | 154 | String get authority => '$_ip:$_port'; 155 | 156 | /// The connection stored in [Settings] will only have an IP if it's been 157 | /// successfully tested. 158 | bool get hasIp => _ip.isNotEmpty; 159 | 160 | bool get isValid => 161 | _ipError == null && _portError == null && _passwordError == null; 162 | 163 | bool get isNotValid => !isValid; 164 | 165 | set ip(String value) { 166 | if (value.trim().isEmpty) { 167 | _ipError = 'An IP address is required'; 168 | } else if (!_ipPattern.hasMatch(value)) { 169 | _ipError = 'Must have 4 parts separated by periods'; 170 | } else { 171 | _ipError = null; 172 | } 173 | _ip = value; 174 | } 175 | 176 | set port(String value) { 177 | _port = value; 178 | if (value.trim().isEmpty) { 179 | _portError = 'A port number is required'; 180 | } else if (!_numericPattern.hasMatch(value)) { 181 | _portError = 'Must be all digits'; 182 | } else { 183 | _portError = null; 184 | } 185 | _port = value; 186 | } 187 | 188 | set password(String value) { 189 | if (value.trim().isEmpty) { 190 | _passwordError = 'A password is required'; 191 | } else { 192 | _passwordError = null; 193 | } 194 | _password = value; 195 | } 196 | 197 | Connection.fromJson(Map json) { 198 | ip = json['ip'] ?? ''; 199 | port = json['port'] ?? defaultPort; 200 | password = json['password'] ?? defaultPassword; 201 | } 202 | 203 | Map toJson() => { 204 | 'ip': ip, 205 | 'port': port, 206 | 'password': password, 207 | }; 208 | } 209 | 210 | class Settings { 211 | final SharedPreferences _prefs; 212 | 213 | late bool blurredCoverBg; 214 | late bool dense; 215 | late Connection connection; 216 | 217 | Settings(this._prefs) { 218 | Map json = 219 | jsonDecode(_prefs.getString('settings') ?? '{}'); 220 | blurredCoverBg = json['blurredCoverBg'] ?? true; 221 | connection = Connection.fromJson(json['connection'] ?? {}); 222 | dense = json['dense'] ?? false; 223 | } 224 | 225 | Map toJson() => { 226 | 'blurredCoverBg': blurredCoverBg, 227 | 'connection': connection, 228 | 'dense': dense, 229 | }; 230 | 231 | save() { 232 | _prefs.setString('settings', jsonEncode(this)); 233 | } 234 | } 235 | 236 | class LanguageTrack { 237 | String name; 238 | int streamNumber; 239 | 240 | LanguageTrack(this.name, this.streamNumber); 241 | 242 | @override 243 | String toString() { 244 | return '$name ($streamNumber)'; 245 | } 246 | } 247 | 248 | class Equalizer { 249 | late bool enabled; 250 | late List presets; 251 | late List bands; 252 | late double preamp; 253 | 254 | @override 255 | String toString() { 256 | if (!enabled) { 257 | return 'Equalizer(off)'; 258 | } 259 | return 'Equalizer(preamp: ${preamp.toStringAsFixed(1)}, bands: ${bands.map((b) => b.value.toStringAsFixed(1)).join(', ')})'; 260 | } 261 | } 262 | 263 | class Band { 264 | int id; 265 | double value; 266 | 267 | Band(this.id, this.value); 268 | } 269 | 270 | class Preset { 271 | int id; 272 | String name; 273 | 274 | Preset(this.id, this.name); 275 | } 276 | 277 | String findFirstElementText(xml.XmlDocument document, String name, 278 | [String fallback = '']) { 279 | var elements = document.findAllElements(name); 280 | if (elements.isNotEmpty) { 281 | return elements.first.text; 282 | } 283 | return fallback; 284 | } 285 | 286 | String findFirstChildElementText(xml.XmlElement element, String name, 287 | [String fallback = '']) { 288 | var elements = element.findElements(name); 289 | if (elements.isNotEmpty) { 290 | return elements.first.text; 291 | } 292 | return fallback; 293 | } 294 | 295 | class VlcStatusResponse { 296 | xml.XmlDocument document; 297 | 298 | List? _audioTracks; 299 | List? _subtitleTracks; 300 | Map? _info; 301 | 302 | VlcStatusResponse(this.document); 303 | 304 | String get state => findFirstElementText(document, 'state'); 305 | 306 | Duration get time => 307 | Duration(seconds: int.parse(findFirstElementText(document, 'time', '0'))); 308 | 309 | Duration get length => Duration( 310 | seconds: int.parse(findFirstElementText(document, 'length', '0'))); 311 | 312 | int get volume { 313 | return int.parse(findFirstElementText(document, 'volume', '256')); 314 | } 315 | 316 | double get rate => double.parse(document.findAllElements('rate').first.text); 317 | 318 | Map get _metadata { 319 | if (_info == null) { 320 | xml.XmlElement? category; 321 | var informations = document.rootElement.findElements('information'); 322 | if (informations.isNotEmpty) { 323 | var categories = informations.first.findElements('category'); 324 | if (categories.isNotEmpty) { 325 | category = categories.first; 326 | } 327 | } 328 | _info = category != null 329 | ? { 330 | for (var el in category.findElements('info')) 331 | el.getAttribute('name') ?? '': el.text 332 | } 333 | : {}; 334 | } 335 | return _info!; 336 | } 337 | 338 | String get title => _metadata['title'] ?? _metadata['filename'] ?? ''; 339 | 340 | String get artist => _metadata['artist'] ?? ''; 341 | 342 | String get artworkUrl => _metadata['artwork_url'] ?? ''; 343 | 344 | bool get fullscreen => findFirstElementText(document, 'fullscreen') == 'true'; 345 | 346 | bool get repeat => findFirstElementText(document, 'repeat') == 'true'; 347 | 348 | bool get random => findFirstElementText(document, 'random') == 'true'; 349 | 350 | bool get loop => findFirstElementText(document, 'loop') == 'true'; 351 | 352 | String get currentPlId => findFirstElementText(document, 'currentplid', '-1'); 353 | 354 | String get version => findFirstElementText(document, 'version'); 355 | 356 | List get audioTracks { 357 | return _audioTracks ??= _getLanguageTracks(_audioTranslations); 358 | } 359 | 360 | List get subtitleTracks { 361 | return _subtitleTracks ??= _getLanguageTracks(_subtitleTranslations); 362 | } 363 | 364 | Equalizer get equalizer { 365 | var equalizer = Equalizer(); 366 | var el = document.rootElement.findElements('equalizer').first; 367 | equalizer.enabled = el.firstChild != null; 368 | if (!equalizer.enabled) { 369 | return equalizer; 370 | } 371 | equalizer.presets = el 372 | .findAllElements('preset') 373 | .map((el) => Preset( 374 | int.parse(el.getAttribute('id')!), 375 | el.text, 376 | )) 377 | .toList(); 378 | equalizer.presets.sort((a, b) => a.id - b.id); 379 | equalizer.bands = el 380 | .findAllElements('band') 381 | .map((el) => Band( 382 | int.parse(el.getAttribute('id')!), 383 | double.parse(el.text), 384 | )) 385 | .toList(); 386 | equalizer.bands.sort((a, b) => a.id - b.id); 387 | equalizer.preamp = double.parse(el.findElements('preamp').first.text); 388 | return equalizer; 389 | } 390 | 391 | List _getLanguageTracks(RegExp type) { 392 | List tracks = []; 393 | document.findAllElements('category').forEach((category) { 394 | Map info = { 395 | for (var info in category.findElements('info')) 396 | info.getAttribute('name')!: info.text 397 | }; 398 | 399 | String? typeKey = firstWhereOrNull( 400 | info.keys, (key) => _typeTranslations.hasMatch(key.trim())); 401 | if (typeKey == null || !type.hasMatch(info[typeKey]!.trim())) { 402 | return; 403 | } 404 | 405 | var streamName = category.getAttribute('name'); 406 | var streamNumber = int.tryParse(streamName!.split(' ').last); 407 | if (streamNumber == null) { 408 | return; 409 | } 410 | 411 | var codec = _getStreamInfoItem(info, _codecTranslations); 412 | var description = _getStreamInfoItem(info, _descriptionTranslations); 413 | var language = _getStreamInfoItem(info, _languageTranslations); 414 | 415 | String name = streamName; 416 | if (description != null && language != null) { 417 | if (description.startsWith(language)) { 418 | name = description; 419 | } else { 420 | name = '$description [$language]'; 421 | } 422 | } else if (language != null) { 423 | name = language; 424 | } else if (description != null) { 425 | name = description; 426 | } else if (codec != null) { 427 | name = codec; 428 | } 429 | 430 | tracks.add(LanguageTrack(name, streamNumber)); 431 | }); 432 | tracks.sort((a, b) => a.streamNumber - b.streamNumber); 433 | return tracks; 434 | } 435 | 436 | String? _getStreamInfoItem(Map info, RegExp name) { 437 | String? key = 438 | firstWhereOrNull(info.keys, (key) => name.hasMatch(key.trim())); 439 | return (key != null && info[key]!.isNotEmpty) ? info[key] : null; 440 | } 441 | 442 | @override 443 | String toString() { 444 | return 'VlcStatusResponse(${{ 445 | 'state': state, 446 | 'time': time, 447 | 'length': length, 448 | 'volume': volume, 449 | 'title': title, 450 | 'fullscreen': fullscreen, 451 | 'repeat': repeat, 452 | 'random': random, 453 | 'loop': loop, 454 | 'currentPlId': currentPlId, 455 | 'version': version, 456 | 'audioTracks': audioTracks, 457 | 'subtitleTracks': subtitleTracks, 458 | }})'; 459 | } 460 | } 461 | 462 | class PlaylistItem { 463 | String id; 464 | String name; 465 | String uri; 466 | Duration duration; 467 | bool current; 468 | 469 | PlaylistItem.fromXmlElement(xml.XmlElement el) 470 | : name = el.getAttribute('name')!, 471 | id = el.getAttribute('id')!, 472 | duration = Duration(seconds: int.parse(el.getAttribute('duration')!)), 473 | uri = el.getAttribute('uri')!, 474 | current = el.getAttribute('current') != null; 475 | 476 | IconData get icon { 477 | if (isDir) { 478 | return Icons.folder; 479 | } 480 | if (isWeb) { 481 | return Icons.public; 482 | } 483 | if (isAudio) { 484 | return Icons.audiotrack; 485 | } 486 | if (isVideo) { 487 | return Icons.movie; 488 | } 489 | return Icons.insert_drive_file; 490 | } 491 | 492 | bool get isAudio => _audioExtensions.hasMatch(uri); 493 | 494 | bool get isDir => uri.startsWith('directory:'); 495 | 496 | bool get isFile => uri.startsWith('file:'); 497 | 498 | bool get isMedia => isAudio || isVideo || isWeb; 499 | 500 | bool get isVideo => _videoExtensions.hasMatch(uri); 501 | 502 | bool get isWeb => uri.startsWith('http'); 503 | 504 | String get title => isVideo ? cleanVideoTitle(name, keepExt: false) : name; 505 | 506 | @override 507 | String toString() { 508 | return 'PlaylistItem(${{ 509 | 'name': name, 510 | 'title': title, 511 | 'id': id, 512 | 'duration': duration, 513 | 'uri': uri, 514 | 'current': current 515 | }})'; 516 | } 517 | } 518 | 519 | class VlcPlaylistResponse { 520 | List items; 521 | PlaylistItem? currentItem; 522 | 523 | VlcPlaylistResponse.fromXmlDocument(xml.XmlDocument doc) 524 | : items = doc.rootElement 525 | .findElements('node') 526 | .first 527 | .findAllElements('leaf') 528 | .map((el) => PlaylistItem.fromXmlElement(el)) 529 | .toList() { 530 | currentItem = firstWhereOrNull(items, (item) => item.current); 531 | } 532 | 533 | @override 534 | String toString() { 535 | return 'VlcPlaylistResponse(${{ 536 | 'items': items, 537 | 'currentItem': currentItem 538 | }})'; 539 | } 540 | } 541 | -------------------------------------------------------------------------------- /lib/open_media.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:shared_preferences/shared_preferences.dart'; 6 | 7 | import 'file_browser.dart'; 8 | import 'models.dart'; 9 | import 'utils.dart'; 10 | import 'widgets.dart'; 11 | 12 | var fileSystemItem = BrowseItem('dir', 'File System', '', 'file:///'); 13 | 14 | /// Allow some commonly-used media URLs without protocols to pass for Copied URL 15 | /// See https://github.com/videolan/vlc/tree/master/share/lua/playlist 16 | var probablyMediaUrlRegExp = RegExp([ 17 | r'(www\.)?dailymotion\.com/video/', 18 | r'(www\.)?soundcloud\.com/.+/.+', 19 | r'((www|gaming)\.)?youtube\.com/|youtu\.be/', 20 | r'(www\.)?vimeo\.com/(channels/.+/)?\d+|player\.vimeo\.com/', 21 | ].join('|')); 22 | 23 | var wwwRegexp = RegExp(r'www\.'); 24 | 25 | class OpenMedia extends StatefulWidget { 26 | final SharedPreferences prefs; 27 | final Settings settings; 28 | 29 | const OpenMedia({ 30 | Key? key, 31 | required this.prefs, 32 | required this.settings, 33 | }) : super(key: key); 34 | 35 | @override 36 | State createState() => _OpenMediaState(); 37 | } 38 | 39 | class _OpenMediaState extends State with WidgetsBindingObserver { 40 | late List _faves; 41 | BrowseItem? _clipboardUrlItem; 42 | String? _otherUrl; 43 | 44 | @override 45 | initState() { 46 | _faves = (jsonDecode(widget.prefs.getString('faves') ?? '[]') as List) 47 | .map((obj) => BrowseItem.fromJson(obj)) 48 | .toList(); 49 | super.initState(); 50 | WidgetsBinding.instance.addObserver(this); 51 | _checkClipboard(); 52 | } 53 | 54 | @override 55 | void dispose() { 56 | WidgetsBinding.instance.removeObserver(this); 57 | super.dispose(); 58 | } 59 | 60 | @override 61 | void didChangeAppLifecycleState(AppLifecycleState state) { 62 | if (state == AppLifecycleState.resumed) { 63 | _checkClipboard(); 64 | } 65 | } 66 | 67 | _checkClipboard() async { 68 | BrowseItem? urlItem; 69 | var data = await Clipboard.getData(Clipboard.kTextPlain); 70 | if (data != null && 71 | data.text != null && 72 | (data.text!.startsWith(urlRegExp) || 73 | data.text!.startsWith(probablyMediaUrlRegExp))) { 74 | urlItem = BrowseItem.fromUrl(data.text!); 75 | } 76 | setState(() { 77 | _clipboardUrlItem = urlItem; 78 | }); 79 | } 80 | 81 | String get _displayUrl => _clipboardUrlItem!.uri 82 | .replaceFirst(urlRegExp, '') 83 | .replaceFirst(wwwRegexp, ''); 84 | 85 | _handleOtherUrl(intent) { 86 | if (_otherUrl == null || _otherUrl!.isEmpty) { 87 | return; 88 | } 89 | Navigator.pop( 90 | context, BrowseResult(BrowseItem.fromUrl(_otherUrl!), intent)); 91 | } 92 | 93 | bool _isFave(BrowseItem item) { 94 | return _faves.any((fave) => item.path == fave.path); 95 | } 96 | 97 | _toggleFave(BrowseItem item) { 98 | setState(() { 99 | var index = _faves.indexWhere((fave) => item.path == fave.path); 100 | if (index != -1) { 101 | _faves.removeAt(index); 102 | } else { 103 | _faves.add(item); 104 | } 105 | widget.prefs.setString('faves', jsonEncode(_faves)); 106 | }); 107 | } 108 | 109 | _selectFile(BrowseItem dir) async { 110 | BrowseResult? result = await Navigator.push( 111 | context, 112 | MaterialPageRoute( 113 | builder: (context) => FileBrowser( 114 | dir: dir, 115 | isFave: _isFave, 116 | onToggleFave: _toggleFave, 117 | settings: widget.settings, 118 | ), 119 | ), 120 | ); 121 | if (result != null) { 122 | if (mounted) { 123 | Navigator.pop(context, result); 124 | } 125 | } 126 | } 127 | 128 | @override 129 | Widget build(BuildContext context) { 130 | List listItems = [ 131 | ListTile( 132 | dense: widget.settings.dense, 133 | title: const Text('File System'), 134 | leading: const Icon(Icons.folder), 135 | onTap: () { 136 | _selectFile(fileSystemItem); 137 | }, 138 | ), 139 | if (_clipboardUrlItem != null) 140 | EnqueueMenuGestureDetector( 141 | item: _clipboardUrlItem!, 142 | child: ListTile( 143 | dense: widget.settings.dense, 144 | title: const Text('Copied URL'), 145 | subtitle: Text(_displayUrl), 146 | leading: const Icon(Icons.public), 147 | onTap: () { 148 | Navigator.pop(context, 149 | BrowseResult(_clipboardUrlItem!, BrowseResultIntent.play)); 150 | }, 151 | ), 152 | ), 153 | ExpansionTile( 154 | leading: const Icon(Icons.public), 155 | title: Text('${_clipboardUrlItem != null ? 'Other ' : ''}URL'), 156 | children: [ 157 | Padding( 158 | padding: const EdgeInsets.symmetric(horizontal: 16), 159 | child: Column( 160 | children: [ 161 | TextFormField( 162 | keyboardType: TextInputType.url, 163 | decoration: const InputDecoration( 164 | border: OutlineInputBorder(), 165 | contentPadding: EdgeInsets.symmetric(horizontal: 12), 166 | ), 167 | onChanged: (url) { 168 | setState(() { 169 | _otherUrl = url; 170 | }); 171 | }, 172 | ), 173 | Row( 174 | children: [ 175 | Expanded( 176 | child: ElevatedButton( 177 | child: const Text('Play'), 178 | onPressed: () => 179 | _handleOtherUrl(BrowseResultIntent.play), 180 | ), 181 | ), 182 | const SizedBox(width: 8), 183 | Expanded( 184 | child: ElevatedButton( 185 | child: const Text('Enqueue'), 186 | onPressed: () => 187 | _handleOtherUrl(BrowseResultIntent.enqueue), 188 | ), 189 | ), 190 | ], 191 | ) 192 | ], 193 | ), 194 | ) 195 | ], 196 | ), 197 | ]; 198 | 199 | if (_faves.isNotEmpty) { 200 | listItems.addAll([ 201 | const Divider(), 202 | ListTile( 203 | dense: widget.settings.dense, 204 | title: Text('Starred', style: Theme.of(context).textTheme.subtitle2), 205 | ), 206 | ]); 207 | listItems.addAll(_faves.map((item) => Dismissible( 208 | key: Key(item.path), 209 | background: const LeaveBehindView(), 210 | child: ListTile( 211 | dense: widget.settings.dense, 212 | title: Text(item.name), 213 | leading: const Icon(Icons.folder_special), 214 | onTap: () { 215 | _selectFile(item); 216 | }, 217 | ), 218 | onDismissed: (direction) { 219 | _toggleFave(item); 220 | }, 221 | ))); 222 | } 223 | 224 | return Scaffold( 225 | appBar: AppBar( 226 | title: const Text('Open Media'), 227 | ), 228 | body: ListView(children: listItems), 229 | ); 230 | } 231 | } 232 | 233 | class LeaveBehindView extends StatelessWidget { 234 | const LeaveBehindView({Key? key}) : super(key: key); 235 | 236 | @override 237 | Widget build(BuildContext context) { 238 | return Container( 239 | color: Colors.red, 240 | padding: const EdgeInsets.all(16.0), 241 | child: Row( 242 | children: const [ 243 | Icon(Icons.delete, color: Colors.white), 244 | Expanded( 245 | child: Text(''), 246 | ), 247 | Icon(Icons.delete, color: Colors.white), 248 | ], 249 | ), 250 | ); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /lib/settings_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:connectivity_plus/connectivity_plus.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter/services.dart'; 8 | import 'package:http/http.dart' as http; 9 | import 'package:network_info_plus/network_info_plus.dart'; 10 | 11 | import 'host_ip_guide.dart'; 12 | import 'models.dart'; 13 | import 'widgets.dart'; 14 | 15 | class SettingsScreen extends StatefulWidget { 16 | final Settings settings; 17 | final Function onSettingsChanged; 18 | 19 | const SettingsScreen( 20 | {Key? key, required this.settings, required this.onSettingsChanged}) 21 | : super(key: key); 22 | 23 | @override 24 | State createState() => _SettingsScreenState(); 25 | } 26 | 27 | class _SettingsScreenState extends State { 28 | Connection connection = Connection(); 29 | var ipController = TextEditingController(); 30 | var ipFocus = FocusNode(); 31 | bool ipDirty = false; 32 | var portController = TextEditingController(); 33 | var portFocus = FocusNode(); 34 | bool portDirty = false; 35 | var passwordController = TextEditingController(); 36 | var passwordFocus = FocusNode(); 37 | bool passwordDirty = false; 38 | 39 | String? prefilledIpSuffix; 40 | bool showPassword = false; 41 | 42 | bool scanningNetwork = false; 43 | 44 | bool testingConnection = false; 45 | String? connectionTestResult; 46 | String? connectionTestResultDescription; 47 | IconData? connectionTestResultIcon; 48 | 49 | @override 50 | initState() { 51 | ipController.addListener(() { 52 | setState(() { 53 | connection.ip = ipController.text; 54 | }); 55 | }); 56 | portController.addListener(() { 57 | setState(() { 58 | connection.port = portController.text; 59 | }); 60 | }); 61 | passwordController.addListener(() { 62 | setState(() { 63 | connection.password = passwordController.text; 64 | }); 65 | }); 66 | 67 | ipController.text = widget.settings.connection.ip; 68 | portController.text = widget.settings.connection.port; 69 | passwordController.text = widget.settings.connection.password; 70 | 71 | ipFocus.addListener(() { 72 | if (!ipFocus.hasFocus) { 73 | setState(() { 74 | ipDirty = true; 75 | }); 76 | } 77 | }); 78 | portFocus.addListener(() { 79 | if (!portFocus.hasFocus) { 80 | setState(() { 81 | portDirty = true; 82 | }); 83 | } 84 | }); 85 | passwordFocus.addListener(() { 86 | if (!passwordFocus.hasFocus) { 87 | setState(() { 88 | passwordDirty = true; 89 | }); 90 | } 91 | }); 92 | 93 | super.initState(); 94 | 95 | if (widget.settings.connection.ip == '') { 96 | _defaultIpPrefix(); 97 | } 98 | } 99 | 100 | @override 101 | dispose() { 102 | ipController.dispose(); 103 | ipFocus.dispose(); 104 | portController.dispose(); 105 | portFocus.dispose(); 106 | passwordController.dispose(); 107 | passwordFocus.dispose(); 108 | super.dispose(); 109 | } 110 | 111 | _defaultIpPrefix() async { 112 | if (await Connectivity().checkConnectivity() == ConnectivityResult.wifi) { 113 | var ip = await NetworkInfo().getWifiIP(); 114 | if (ip != null) { 115 | setState(() { 116 | prefilledIpSuffix = ip.substring(0, ip.lastIndexOf('.') + 1); 117 | ipController.text = prefilledIpSuffix!; 118 | }); 119 | } 120 | } 121 | } 122 | 123 | _testConnection() async { 124 | removeCurrentFocus(context); 125 | if (!connection.isValid) { 126 | setState(() { 127 | ipDirty = true; 128 | portDirty = true; 129 | passwordDirty = true; 130 | }); 131 | return; 132 | } 133 | 134 | setState(() { 135 | connectionTestResult = null; 136 | connectionTestResultIcon = null; 137 | connectionTestResultDescription = null; 138 | testingConnection = true; 139 | }); 140 | 141 | String result; 142 | String description; 143 | IconData icon; 144 | 145 | try { 146 | var response = await http.get( 147 | Uri.http( 148 | '${ipController.text}:${portController.text}', 149 | '/requests/status.xml', 150 | ), 151 | headers: { 152 | 'Authorization': 153 | 'Basic ${base64Encode(utf8.encode(':${passwordController.text}'))}' 154 | }).timeout(const Duration(seconds: 2)); 155 | 156 | if (response.statusCode == 200) { 157 | widget.settings.connection = connection; 158 | widget.onSettingsChanged(); 159 | result = 'Connection successful'; 160 | description = 'Connection settings saved'; 161 | icon = Icons.check; 162 | } else { 163 | icon = Icons.error; 164 | if (response.statusCode == 401) { 165 | result = 'Password is invalid'; 166 | description = 'Tap the eye icon to check your password'; 167 | } else { 168 | result = 'Unexpected response'; 169 | description = 'Status code: ${response.statusCode}'; 170 | } 171 | } 172 | } catch (e) { 173 | description = 'Check the IP and port settings'; 174 | icon = Icons.error; 175 | if (e is TimeoutException) { 176 | result = 'Connection timed out'; 177 | } else if (e is SocketException) { 178 | result = 'Connection error'; 179 | } else { 180 | result = 'Connection error: ${e.runtimeType}'; 181 | } 182 | } 183 | 184 | setState(() { 185 | connectionTestResult = result; 186 | connectionTestResultDescription = description; 187 | connectionTestResultIcon = icon; 188 | testingConnection = false; 189 | }); 190 | } 191 | 192 | @override 193 | Widget build(BuildContext context) { 194 | final theme = Theme.of(context); 195 | final headingStyle = theme.textTheme.subtitle1! 196 | .copyWith(fontWeight: FontWeight.bold, color: theme.primaryColor); 197 | return Scaffold( 198 | appBar: AppBar( 199 | centerTitle: true, 200 | title: const Text('Settings'), 201 | ), 202 | body: ListView(children: [ 203 | ListTile( 204 | dense: widget.settings.dense, 205 | title: Text( 206 | 'VLC connection', 207 | style: headingStyle, 208 | ), 209 | ), 210 | ListTile( 211 | dense: widget.settings.dense, 212 | title: TextField( 213 | controller: ipController, 214 | focusNode: ipFocus, 215 | keyboardType: const TextInputType.numberWithOptions(decimal: true), 216 | inputFormatters: [ipWhitelistingTextInputFormatter], 217 | decoration: InputDecoration( 218 | isDense: widget.settings.dense, 219 | icon: const Icon(Icons.computer), 220 | labelText: 'Host IP', 221 | errorText: ipDirty ? connection.ipError : null, 222 | helperText: prefilledIpSuffix != null && 223 | connection.ip == prefilledIpSuffix 224 | ? 'Suffix pre-filled from your Wi-Fi IP' 225 | : null, 226 | ), 227 | ), 228 | trailing: IconButton( 229 | onPressed: () { 230 | Navigator.push(context, 231 | MaterialPageRoute(builder: (context) => const HostIpGuide())); 232 | }, 233 | tooltip: 'Get help finding your IP', 234 | icon: Icon(Icons.help, color: theme.primaryColor), 235 | ), 236 | ), 237 | ListTile( 238 | dense: widget.settings.dense, 239 | title: TextField( 240 | controller: passwordController, 241 | focusNode: passwordFocus, 242 | obscureText: !showPassword, 243 | decoration: InputDecoration( 244 | isDense: widget.settings.dense, 245 | icon: const Icon(Icons.vpn_key), 246 | labelText: 'Password', 247 | errorText: passwordDirty ? connection.passwordError : null, 248 | ), 249 | ), 250 | trailing: IconButton( 251 | onPressed: () { 252 | setState(() { 253 | showPassword = !showPassword; 254 | }); 255 | }, 256 | tooltip: 'Toggle password visibility', 257 | icon: Icon(Icons.remove_red_eye, 258 | color: showPassword ? theme.primaryColor : null), 259 | ), 260 | ), 261 | ListTile( 262 | dense: widget.settings.dense, 263 | title: TextField( 264 | controller: portController, 265 | focusNode: portFocus, 266 | keyboardType: TextInputType.number, 267 | inputFormatters: [FilteringTextInputFormatter.digitsOnly], 268 | decoration: InputDecoration( 269 | isDense: widget.settings.dense, 270 | icon: const Icon(Icons.input), 271 | labelText: 'Port (default: 8080)', 272 | errorText: portDirty ? connection.portError : null, 273 | helperText: 'Advanced use only'), 274 | ), 275 | ), 276 | ListTile( 277 | dense: widget.settings.dense, 278 | title: ElevatedButton( 279 | style: ElevatedButton.styleFrom( 280 | primary: theme.buttonTheme.colorScheme!.primary, 281 | onPrimary: Colors.white, 282 | ), 283 | onPressed: !testingConnection ? _testConnection : null, 284 | child: Row( 285 | mainAxisSize: MainAxisSize.min, 286 | children: [ 287 | testingConnection 288 | ? const Padding( 289 | padding: EdgeInsets.all(4.0), 290 | child: SizedBox( 291 | width: 16, 292 | height: 16, 293 | child: CircularProgressIndicator( 294 | valueColor: 295 | AlwaysStoppedAnimation(Colors.white)), 296 | ), 297 | ) 298 | : const Icon(Icons.network_check), 299 | const SizedBox(width: 8.0), 300 | const Text('Test & Save Connection'), 301 | ], 302 | ), 303 | ), 304 | ), 305 | if (connectionTestResult != null) 306 | ListTile( 307 | dense: widget.settings.dense, 308 | leading: Icon( 309 | connectionTestResultIcon, 310 | color: connectionTestResultIcon == Icons.check 311 | ? Colors.green 312 | : Colors.redAccent, 313 | ), 314 | title: Text(connectionTestResult!), 315 | subtitle: connectionTestResultDescription != null 316 | ? Text(connectionTestResultDescription!) 317 | : null, 318 | ), 319 | const Divider(), 320 | ListTile( 321 | dense: widget.settings.dense, 322 | title: Text( 323 | 'Display options', 324 | style: headingStyle, 325 | ), 326 | ), 327 | CheckboxListTile( 328 | title: const Text('Compact display'), 329 | value: widget.settings.dense, 330 | dense: widget.settings.dense, 331 | onChanged: (dense) { 332 | setState(() { 333 | widget.settings.dense = dense ?? false; 334 | widget.onSettingsChanged(); 335 | }); 336 | }, 337 | ), 338 | CheckboxListTile( 339 | title: const Text('Blurred cover background'), 340 | subtitle: const Text('When available for audio files'), 341 | value: widget.settings.blurredCoverBg, 342 | dense: widget.settings.dense, 343 | onChanged: (dense) { 344 | setState(() { 345 | widget.settings.blurredCoverBg = dense ?? false; 346 | widget.onSettingsChanged(); 347 | }); 348 | }, 349 | ), 350 | ]), 351 | ); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /lib/utils.dart: -------------------------------------------------------------------------------- 1 | var _dot = RegExp(r'\.'); 2 | 3 | var _episode = RegExp(r's\d\de\d\d', caseSensitive: false); 4 | 5 | // From https://en.wikipedia.org/wiki/Pirated_movie_release_types#Common_abbreviations 6 | var _source = [ 7 | 'ABC', 8 | 'ATVP', 9 | 'AMZN', 10 | 'BBC', 11 | 'CBS', 12 | 'CC', 13 | 'CR', 14 | 'CW', 15 | 'DCU', 16 | 'DSNY', 17 | 'FBWatch', 18 | 'FREE', 19 | 'FOX', 20 | 'HULU', 21 | 'iP', 22 | 'LIFE', 23 | 'MTV', 24 | 'NBC', 25 | 'NICK', 26 | 'NF', 27 | 'RED', 28 | 'TF1', 29 | 'STZ', 30 | ].map((s) => RegExp.escape(s)).join('|'); 31 | 32 | // From https://en.wikipedia.org/wiki/Pirated_movie_release_types#Release_formats 33 | var _format = [ 34 | 'CAMRip', 35 | 'CAM', 36 | 'HDCAM', 37 | 'TS', 38 | 'HDTS', 39 | 'TELESYNC', 40 | 'PDVD', 41 | 'PreDVDRip', 42 | 'WP', 43 | 'WORKPRINT', 44 | 'TC', 45 | 'HDTC', 46 | 'TELECINE', 47 | 'PPV', 48 | 'PPVRip', 49 | 'SCR', 50 | 'SCREENER', 51 | 'DVDSCR', 52 | 'DVDSCREENER', 53 | 'BDSCR', 54 | 'DDC', 55 | 'R5', 56 | 'R5.LINE', 57 | 'R5.AC3.5.1.HQ', 58 | 'DVDRip', 59 | 'DVDMux', 60 | 'DVDR', 61 | 'DVD-Full', 62 | 'Full-Rip', 63 | 'ISO rip', 64 | 'lossless rip', 65 | 'untouched rip', 66 | 'DVD-5', 67 | 'DVD-9', 68 | 'DSR', 69 | 'DSRip', 70 | 'SATRip', 71 | 'DTHRip', 72 | 'DVBRip', 73 | 'HDTV', 74 | 'PDTV', 75 | 'DTVRip', 76 | 'TVRip', 77 | 'HDTVRip', 78 | 'VODRip', 79 | 'VODR', 80 | 'WEBDL', 81 | 'WEB DL', 82 | 'WEB-DL', 83 | 'HDRip', 84 | 'WEB-DLRip', 85 | 'WEBRip', 86 | 'WEB Rip', 87 | 'WEB-Rip', 88 | 'WEB', 89 | 'WEB-Cap', 90 | 'WEBCAP', 91 | 'WEB Cap', 92 | 'HC', 93 | 'HD-Rip', 94 | 'Blu-Ray', 95 | 'BluRay', 96 | 'BDRip', 97 | 'BRip', 98 | 'BRRip', 99 | 'BDMV', 100 | 'BDR', 101 | 'BD25', 102 | 'BD50', 103 | 'BD5', 104 | 'BD9', 105 | ].map((s) => RegExp.escape(s)).join('|'); 106 | 107 | var _year = r'\d{4}'; 108 | 109 | // 720p, 1080p etc. 110 | var _res = r'\d{3,4}p?'; 111 | 112 | // DUBBED, JAPANESE, INDONESIAN etc. 113 | var _language = r'[A-Z]+'; 114 | 115 | var _movie = RegExp( 116 | '\\.$_year(\\.$_language)?(\\.$_res)?(\\.($_source))?\\.($_format)', 117 | caseSensitive: false, 118 | ); 119 | 120 | String cleanVideoTitle(String name, {bool keepExt = false}) { 121 | if (name == '') { 122 | return ''; 123 | } 124 | if (_episode.hasMatch(name)) { 125 | return dotsToSpaces(name.substring(0, _episode.firstMatch(name)!.end)); 126 | } 127 | if (_movie.hasMatch(name)) { 128 | return dotsToSpaces(name.substring(0, _movie.firstMatch(name)!.start)); 129 | } 130 | return dotsToSpaces(name, keepExt: keepExt); 131 | } 132 | 133 | String dotsToSpaces(String s, {bool keepExt = false}) { 134 | String ext = ''; 135 | var parts = s.split(_dot); 136 | if (keepExt) { 137 | ext = parts.removeLast(); 138 | } 139 | return '${parts.join(' ')}.$ext'; 140 | } 141 | 142 | /// [Iterable.firstWhere] doesn't work with null safety when you want to fall 143 | /// back to returning `null`. 144 | /// 145 | /// See https://github.com/dart-lang/sdk/issues/42947 146 | T? firstWhereOrNull(Iterable iterable, bool Function(T element) test) { 147 | for (var element in iterable) { 148 | if (test(element)) return element; 149 | } 150 | return null; 151 | } 152 | 153 | String formatTime(Duration duration) { 154 | String minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); 155 | String seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); 156 | return '${duration.inHours >= 1 ? '${duration.inHours}:' : ''}$minutes:$seconds'; 157 | } 158 | 159 | /// Matches some of the protocols supported by VLC. 160 | var urlRegExp = RegExp(r'((f|ht)tps?|mms|rts?p)://'); 161 | 162 | /* 163 | * Trick stolen from https://gist.github.com/shubhamjain/9809108#file-vlc_http-L108 164 | * The interface expects value between 0 and 512 while in the UI it is 0% to 200%. 165 | * So a factor of 2.56 is used to convert 0% to 200% to a scale of 0 to 512. 166 | */ 167 | const volumeSliderScaleFactor = 2.56; 168 | -------------------------------------------------------------------------------- /lib/vlc_configuration_guide.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'models.dart'; 4 | import 'widgets.dart'; 5 | 6 | class VlcConfigurationGuide extends StatefulWidget { 7 | const VlcConfigurationGuide({Key? key}) : super(key: key); 8 | 9 | @override 10 | State createState() => _VlcConfigurationGuideState(); 11 | } 12 | 13 | class _VlcConfigurationGuideState extends State { 14 | int _currentStep = 0; 15 | OperatingSystem? _os; 16 | 17 | _onOsChanged(os) { 18 | setState(() { 19 | _os = os; 20 | }); 21 | } 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return Scaffold( 26 | appBar: AppBar( 27 | centerTitle: true, 28 | title: Column( 29 | crossAxisAlignment: CrossAxisAlignment.end, 30 | children: [ 31 | const Text('VLC Configuration Guide'), 32 | if (_os != null) 33 | Text('for ${osNames[_os]}', style: const TextStyle(fontSize: 13)), 34 | ], 35 | ), 36 | ), 37 | body: buildBody(), 38 | ); 39 | } 40 | 41 | Widget buildBody() { 42 | switch (_os) { 43 | case OperatingSystem.macos: 44 | return buildMacStepper(); 45 | case OperatingSystem.linux: 46 | case OperatingSystem.windows: 47 | return buildLinuxWindowsStepper(); 48 | default: 49 | var theme = Theme.of(context); 50 | return Column( 51 | children: [ 52 | Padding( 53 | padding: const EdgeInsets.all(24), 54 | child: Wrap( 55 | runSpacing: 16, 56 | children: [ 57 | Text('Which operating system are you running VLC on?', 58 | style: theme.textTheme.subtitle1), 59 | Column( 60 | children: OperatingSystem.values 61 | .map((os) => RadioListTile( 62 | title: Text(osNames[os]!), 63 | value: os, 64 | groupValue: _os, 65 | onChanged: _onOsChanged, 66 | )) 67 | .toList()) 68 | ], 69 | ), 70 | ), 71 | ], 72 | ); 73 | } 74 | } 75 | 76 | Widget buildLinuxWindowsStepper() { 77 | var theme = Theme.of(context); 78 | var os = _os.toString().split('.').last; 79 | return Stepper( 80 | currentStep: _currentStep, 81 | controlsBuilder: (BuildContext context, ControlsDetails details) { 82 | return Padding( 83 | padding: const EdgeInsets.only(top: 16), 84 | child: Row( 85 | children: [ 86 | TextButton( 87 | style: TextButton.styleFrom( 88 | backgroundColor: theme.primaryColor, 89 | primary: Colors.white, 90 | ), 91 | onPressed: details.onStepContinue, 92 | child: Text(_currentStep == 2 ? 'FINISHED' : 'NEXT STEP'), 93 | ), 94 | TextButton( 95 | onPressed: details.onStepCancel, 96 | child: const Text('PREVIOUS STEP'), 97 | ), 98 | ], 99 | ), 100 | ); 101 | }, 102 | onStepCancel: () { 103 | if (_currentStep > 0) { 104 | setState(() { 105 | _currentStep--; 106 | }); 107 | } else { 108 | _onOsChanged(null); 109 | } 110 | }, 111 | onStepContinue: () { 112 | if (_currentStep == 2) { 113 | Navigator.pop(context); 114 | return; 115 | } 116 | setState(() { 117 | _currentStep++; 118 | }); 119 | }, 120 | steps: [ 121 | Step( 122 | title: const Text('Enable VLC\'s web interface'), 123 | content: TextAndImages( 124 | children: [ 125 | const Text( 126 | 'In VLC\'s menu bar, select Tools > Preferences to open the preferences window:'), 127 | Image.asset('assets/$os-menu.png'), 128 | const Text( 129 | 'Switch to Advanced Preferences mode by clicking the "All" radio button in the "Show settings" section at the bottom left of the window:'), 130 | Image.asset('assets/$os-show-settings.png'), 131 | const Text( 132 | 'Scroll down to find the "Main interfaces" section and click it:'), 133 | Image.asset('assets/$os-main-interface.png'), 134 | const Text( 135 | 'Check the "Web" checkbox in the "Extra interface modules" section to enable the web interface:'), 136 | Image.asset('assets/$os-web.png'), 137 | ], 138 | ), 139 | ), 140 | Step( 141 | title: const Text('Set web interface password'), 142 | content: TextAndImages( 143 | children: [ 144 | const Text( 145 | 'Expand the "Main interfaces" section by clicking the ">" chevron and click the "Lua" section which appears:'), 146 | Image.asset('assets/$os-lua.png'), 147 | const Text('Set a password in the "Lua HTTP" section:'), 148 | Image.asset('assets/$os-password.png'), 149 | const Text( 150 | 'Remote Control for VLC uses the password "vlcplayer" (without quotes) by default – if you set something else you\'ll have to manually configure the VLC connection.'), 151 | const Text('Finally, click Save to save your changes.'), 152 | ], 153 | ), 154 | ), 155 | Step( 156 | title: const Text('Close and restart VLC'), 157 | content: Row( 158 | children: const [ 159 | Text('Close and restart VLC to activate the web interface.'), 160 | ], 161 | ), 162 | ) 163 | ], 164 | ); 165 | } 166 | 167 | Widget buildMacStepper() { 168 | var theme = Theme.of(context); 169 | return Stepper( 170 | currentStep: _currentStep, 171 | controlsBuilder: (BuildContext context, ControlsDetails details) { 172 | return Padding( 173 | padding: const EdgeInsets.only(top: 16), 174 | child: Row( 175 | children: [ 176 | TextButton( 177 | style: TextButton.styleFrom( 178 | backgroundColor: theme.primaryColor, 179 | primary: Colors.white, 180 | ), 181 | onPressed: details.onStepContinue, 182 | child: Text(_currentStep == 1 ? 'FINISHED' : 'NEXT STEP'), 183 | ), 184 | TextButton( 185 | onPressed: details.onStepCancel, 186 | child: const Text('PREVIOUS STEP'), 187 | ), 188 | ], 189 | ), 190 | ); 191 | }, 192 | onStepCancel: () { 193 | if (_currentStep > 0) { 194 | setState(() { 195 | _currentStep--; 196 | }); 197 | } else { 198 | _onOsChanged(null); 199 | } 200 | }, 201 | onStepContinue: () { 202 | if (_currentStep == 1) { 203 | Navigator.pop(context); 204 | return; 205 | } 206 | setState(() { 207 | _currentStep++; 208 | }); 209 | }, 210 | steps: [ 211 | Step( 212 | title: const Text('Enable VLC\'s web interface'), 213 | content: TextAndImages( 214 | children: [ 215 | const Text( 216 | 'In the Menubar, select VLC > Preferences to open the preferences window:'), 217 | Image.asset('assets/mac-menu.png'), 218 | const Text( 219 | 'At the bottom of the "Interface" settings page, check "Enable HTTP web interface" and set a password.'), 220 | Image.asset('assets/mac-http-interface.png'), 221 | const Text( 222 | 'Remote Control for VLC uses the password "vlcplayer" (without quotes) by default – if you set something else you\'ll have to manually configure the VLC connection.'), 223 | const Text('Finally, click Save to save your changes.'), 224 | ], 225 | ), 226 | ), 227 | Step( 228 | title: const Text('Quit and restart VLC'), 229 | content: Row( 230 | children: const [ 231 | Text('Quit and restart VLC to activate the web interface.'), 232 | ], 233 | ), 234 | ) 235 | ], 236 | ); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /lib/widgets.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | 6 | import 'models.dart'; 7 | 8 | class EnqueueMenuGestureDetector extends StatefulWidget { 9 | final Widget child; 10 | final BrowseItem item; 11 | 12 | const EnqueueMenuGestureDetector( 13 | {Key? key, required this.child, required this.item}) 14 | : super(key: key); 15 | 16 | @override 17 | State createState() => 18 | _EnqueueMenuGestureDetectorState(); 19 | } 20 | 21 | class _EnqueueMenuGestureDetectorState 22 | extends State { 23 | late Offset _tapPosition; 24 | 25 | _handleTapDown(details) { 26 | _tapPosition = details.globalPosition; 27 | } 28 | 29 | _showMenu() async { 30 | final Size size = Overlay.of(context)!.context.size!; 31 | var intent = await showMenu( 32 | context: context, 33 | items: >[ 34 | const PopupMenuItem( 35 | value: BrowseResultIntent.play, 36 | child: Text('Play'), 37 | ), 38 | const PopupMenuItem( 39 | value: BrowseResultIntent.enqueue, 40 | child: Text('Enqueue'), 41 | ), 42 | ], 43 | position: RelativeRect.fromRect( 44 | _tapPosition & const Size(40, 40), Offset.zero & size), 45 | ); 46 | if (intent != null) { 47 | if (mounted) { 48 | Navigator.pop(context, BrowseResult(widget.item, intent)); 49 | } 50 | } 51 | } 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | return GestureDetector( 56 | onTapDown: _handleTapDown, 57 | onLongPress: _showMenu, 58 | child: widget.child, 59 | ); 60 | } 61 | } 62 | 63 | /// A custom track shape for a [Slider] which lets it go full-width. 64 | /// 65 | /// From https://github.com/flutter/flutter/issues/37057#issuecomment-516048356 66 | class FullWidthTrackShape extends RoundedRectSliderTrackShape { 67 | @override 68 | Rect getPreferredRect({ 69 | required RenderBox parentBox, 70 | Offset offset = Offset.zero, 71 | required SliderThemeData sliderTheme, 72 | bool isEnabled = false, 73 | bool isDiscrete = false, 74 | }) { 75 | final double trackHeight = sliderTheme.trackHeight!; 76 | final double trackLeft = offset.dx; 77 | final double trackTop = 78 | offset.dy + (parentBox.size.height - trackHeight) / 2; 79 | final double trackWidth = parentBox.size.width; 80 | return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); 81 | } 82 | } 83 | 84 | var _intlStrings = { 85 | 'Equalizer': 'Equaliser', 86 | 'Settings': 'Settings', 87 | }; 88 | 89 | String intl(String enUsString) { 90 | if (ui.window.locale.countryCode == 'US' || 91 | !_intlStrings.containsKey(enUsString)) { 92 | return enUsString; 93 | } 94 | return _intlStrings[enUsString]!; 95 | } 96 | 97 | /// Like [Iterable.join] but for lists of Widgets. 98 | Iterable intersperseWidgets(Iterable iterable, 99 | {required Widget Function() builder}) sync* { 100 | final iterator = iterable.iterator; 101 | if (iterator.moveNext()) { 102 | yield iterator.current; 103 | while (iterator.moveNext()) { 104 | yield builder(); 105 | yield iterator.current; 106 | } 107 | } 108 | } 109 | 110 | /// A [WhitelistingTextInputFormatter] that takes in digits `[0-9]` and periods 111 | /// `.` only. 112 | var ipWhitelistingTextInputFormatter = 113 | FilteringTextInputFormatter.allow(RegExp(r'[\d.]+')); 114 | 115 | /// Remove current focus to hide the keyboard. 116 | removeCurrentFocus(BuildContext context) { 117 | FocusScopeNode currentFocus = FocusScope.of(context); 118 | if (!currentFocus.hasPrimaryFocus) { 119 | currentFocus.unfocus(); 120 | } 121 | } 122 | 123 | class TextAndImages extends StatelessWidget { 124 | final List children; 125 | final double spacing; 126 | 127 | const TextAndImages({Key? key, required this.children, this.spacing = 16}) 128 | : super(key: key); 129 | 130 | @override 131 | Widget build(BuildContext context) { 132 | return Column( 133 | children: intersperseWidgets( 134 | children.map((child) => Row(children: [ 135 | Expanded( 136 | child: Container( 137 | alignment: Alignment.topLeft, 138 | child: child, 139 | )) 140 | ])), 141 | builder: () => SizedBox(height: spacing), 142 | ).toList(), 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | args: 5 | dependency: transitive 6 | description: 7 | name: args 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "2.3.1" 11 | async: 12 | dependency: transitive 13 | description: 14 | name: async 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "2.8.2" 18 | boolean_selector: 19 | dependency: transitive 20 | description: 21 | name: boolean_selector 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "2.1.0" 25 | characters: 26 | dependency: transitive 27 | description: 28 | name: characters 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "1.2.0" 32 | charcode: 33 | dependency: transitive 34 | description: 35 | name: charcode 36 | url: "https://pub.dartlang.org" 37 | source: hosted 38 | version: "1.3.1" 39 | clock: 40 | dependency: transitive 41 | description: 42 | name: clock 43 | url: "https://pub.dartlang.org" 44 | source: hosted 45 | version: "1.1.0" 46 | collection: 47 | dependency: transitive 48 | description: 49 | name: collection 50 | url: "https://pub.dartlang.org" 51 | source: hosted 52 | version: "1.16.0" 53 | connectivity_plus: 54 | dependency: "direct main" 55 | description: 56 | name: connectivity_plus 57 | url: "https://pub.dartlang.org" 58 | source: hosted 59 | version: "2.3.0" 60 | connectivity_plus_linux: 61 | dependency: transitive 62 | description: 63 | name: connectivity_plus_linux 64 | url: "https://pub.dartlang.org" 65 | source: hosted 66 | version: "1.3.0" 67 | connectivity_plus_macos: 68 | dependency: transitive 69 | description: 70 | name: connectivity_plus_macos 71 | url: "https://pub.dartlang.org" 72 | source: hosted 73 | version: "1.2.2" 74 | connectivity_plus_platform_interface: 75 | dependency: transitive 76 | description: 77 | name: connectivity_plus_platform_interface 78 | url: "https://pub.dartlang.org" 79 | source: hosted 80 | version: "1.2.0" 81 | connectivity_plus_web: 82 | dependency: transitive 83 | description: 84 | name: connectivity_plus_web 85 | url: "https://pub.dartlang.org" 86 | source: hosted 87 | version: "1.2.0" 88 | connectivity_plus_windows: 89 | dependency: transitive 90 | description: 91 | name: connectivity_plus_windows 92 | url: "https://pub.dartlang.org" 93 | source: hosted 94 | version: "1.2.0" 95 | crypto: 96 | dependency: transitive 97 | description: 98 | name: crypto 99 | url: "https://pub.dartlang.org" 100 | source: hosted 101 | version: "3.0.2" 102 | dart_ping: 103 | dependency: transitive 104 | description: 105 | name: dart_ping 106 | url: "https://pub.dartlang.org" 107 | source: hosted 108 | version: "6.1.2" 109 | dbus: 110 | dependency: transitive 111 | description: 112 | name: dbus 113 | url: "https://pub.dartlang.org" 114 | source: hosted 115 | version: "0.7.3" 116 | fake_async: 117 | dependency: transitive 118 | description: 119 | name: fake_async 120 | url: "https://pub.dartlang.org" 121 | source: hosted 122 | version: "1.3.0" 123 | ffi: 124 | dependency: transitive 125 | description: 126 | name: ffi 127 | url: "https://pub.dartlang.org" 128 | source: hosted 129 | version: "1.1.2" 130 | file: 131 | dependency: transitive 132 | description: 133 | name: file 134 | url: "https://pub.dartlang.org" 135 | source: hosted 136 | version: "6.1.2" 137 | flutter: 138 | dependency: "direct main" 139 | description: flutter 140 | source: sdk 141 | version: "0.0.0" 142 | flutter_lints: 143 | dependency: "direct dev" 144 | description: 145 | name: flutter_lints 146 | url: "https://pub.dartlang.org" 147 | source: hosted 148 | version: "2.0.1" 149 | flutter_test: 150 | dependency: "direct dev" 151 | description: flutter 152 | source: sdk 153 | version: "0.0.0" 154 | flutter_web_plugins: 155 | dependency: transitive 156 | description: flutter 157 | source: sdk 158 | version: "0.0.0" 159 | http: 160 | dependency: "direct main" 161 | description: 162 | name: http 163 | url: "https://pub.dartlang.org" 164 | source: hosted 165 | version: "0.13.4" 166 | http_parser: 167 | dependency: transitive 168 | description: 169 | name: http_parser 170 | url: "https://pub.dartlang.org" 171 | source: hosted 172 | version: "4.0.0" 173 | intl: 174 | dependency: transitive 175 | description: 176 | name: intl 177 | url: "https://pub.dartlang.org" 178 | source: hosted 179 | version: "0.17.0" 180 | js: 181 | dependency: transitive 182 | description: 183 | name: js 184 | url: "https://pub.dartlang.org" 185 | source: hosted 186 | version: "0.6.4" 187 | just_throttle_it: 188 | dependency: "direct main" 189 | description: 190 | name: just_throttle_it 191 | url: "https://pub.dartlang.org" 192 | source: hosted 193 | version: "2.1.0" 194 | lints: 195 | dependency: transitive 196 | description: 197 | name: lints 198 | url: "https://pub.dartlang.org" 199 | source: hosted 200 | version: "2.0.0" 201 | logging: 202 | dependency: transitive 203 | description: 204 | name: logging 205 | url: "https://pub.dartlang.org" 206 | source: hosted 207 | version: "1.0.2" 208 | matcher: 209 | dependency: transitive 210 | description: 211 | name: matcher 212 | url: "https://pub.dartlang.org" 213 | source: hosted 214 | version: "0.12.11" 215 | material_color_utilities: 216 | dependency: transitive 217 | description: 218 | name: material_color_utilities 219 | url: "https://pub.dartlang.org" 220 | source: hosted 221 | version: "0.1.4" 222 | meta: 223 | dependency: transitive 224 | description: 225 | name: meta 226 | url: "https://pub.dartlang.org" 227 | source: hosted 228 | version: "1.7.0" 229 | network_info_plus: 230 | dependency: "direct main" 231 | description: 232 | name: network_info_plus 233 | url: "https://pub.dartlang.org" 234 | source: hosted 235 | version: "2.1.3" 236 | network_info_plus_linux: 237 | dependency: transitive 238 | description: 239 | name: network_info_plus_linux 240 | url: "https://pub.dartlang.org" 241 | source: hosted 242 | version: "1.1.2" 243 | network_info_plus_macos: 244 | dependency: transitive 245 | description: 246 | name: network_info_plus_macos 247 | url: "https://pub.dartlang.org" 248 | source: hosted 249 | version: "1.3.0" 250 | network_info_plus_platform_interface: 251 | dependency: transitive 252 | description: 253 | name: network_info_plus_platform_interface 254 | url: "https://pub.dartlang.org" 255 | source: hosted 256 | version: "1.1.2" 257 | network_info_plus_web: 258 | dependency: transitive 259 | description: 260 | name: network_info_plus_web 261 | url: "https://pub.dartlang.org" 262 | source: hosted 263 | version: "1.0.1" 264 | network_info_plus_windows: 265 | dependency: transitive 266 | description: 267 | name: network_info_plus_windows 268 | url: "https://pub.dartlang.org" 269 | source: hosted 270 | version: "1.0.2" 271 | network_tools: 272 | dependency: "direct main" 273 | description: 274 | name: network_tools 275 | url: "https://pub.dartlang.org" 276 | source: hosted 277 | version: "1.0.8" 278 | nm: 279 | dependency: transitive 280 | description: 281 | name: nm 282 | url: "https://pub.dartlang.org" 283 | source: hosted 284 | version: "0.5.0" 285 | path: 286 | dependency: transitive 287 | description: 288 | name: path 289 | url: "https://pub.dartlang.org" 290 | source: hosted 291 | version: "1.8.1" 292 | path_provider_linux: 293 | dependency: transitive 294 | description: 295 | name: path_provider_linux 296 | url: "https://pub.dartlang.org" 297 | source: hosted 298 | version: "2.1.6" 299 | path_provider_platform_interface: 300 | dependency: transitive 301 | description: 302 | name: path_provider_platform_interface 303 | url: "https://pub.dartlang.org" 304 | source: hosted 305 | version: "2.0.4" 306 | path_provider_windows: 307 | dependency: transitive 308 | description: 309 | name: path_provider_windows 310 | url: "https://pub.dartlang.org" 311 | source: hosted 312 | version: "2.0.6" 313 | petitparser: 314 | dependency: transitive 315 | description: 316 | name: petitparser 317 | url: "https://pub.dartlang.org" 318 | source: hosted 319 | version: "5.0.0" 320 | platform: 321 | dependency: transitive 322 | description: 323 | name: platform 324 | url: "https://pub.dartlang.org" 325 | source: hosted 326 | version: "3.1.0" 327 | plugin_platform_interface: 328 | dependency: transitive 329 | description: 330 | name: plugin_platform_interface 331 | url: "https://pub.dartlang.org" 332 | source: hosted 333 | version: "2.1.2" 334 | process: 335 | dependency: transitive 336 | description: 337 | name: process 338 | url: "https://pub.dartlang.org" 339 | source: hosted 340 | version: "4.2.4" 341 | shared_preferences: 342 | dependency: "direct main" 343 | description: 344 | name: shared_preferences 345 | url: "https://pub.dartlang.org" 346 | source: hosted 347 | version: "2.0.15" 348 | shared_preferences_android: 349 | dependency: transitive 350 | description: 351 | name: shared_preferences_android 352 | url: "https://pub.dartlang.org" 353 | source: hosted 354 | version: "2.0.12" 355 | shared_preferences_ios: 356 | dependency: transitive 357 | description: 358 | name: shared_preferences_ios 359 | url: "https://pub.dartlang.org" 360 | source: hosted 361 | version: "2.1.1" 362 | shared_preferences_linux: 363 | dependency: transitive 364 | description: 365 | name: shared_preferences_linux 366 | url: "https://pub.dartlang.org" 367 | source: hosted 368 | version: "2.1.1" 369 | shared_preferences_macos: 370 | dependency: transitive 371 | description: 372 | name: shared_preferences_macos 373 | url: "https://pub.dartlang.org" 374 | source: hosted 375 | version: "2.0.4" 376 | shared_preferences_platform_interface: 377 | dependency: transitive 378 | description: 379 | name: shared_preferences_platform_interface 380 | url: "https://pub.dartlang.org" 381 | source: hosted 382 | version: "2.0.0" 383 | shared_preferences_web: 384 | dependency: transitive 385 | description: 386 | name: shared_preferences_web 387 | url: "https://pub.dartlang.org" 388 | source: hosted 389 | version: "2.0.4" 390 | shared_preferences_windows: 391 | dependency: transitive 392 | description: 393 | name: shared_preferences_windows 394 | url: "https://pub.dartlang.org" 395 | source: hosted 396 | version: "2.1.1" 397 | sky_engine: 398 | dependency: transitive 399 | description: flutter 400 | source: sdk 401 | version: "0.0.99" 402 | source_span: 403 | dependency: transitive 404 | description: 405 | name: source_span 406 | url: "https://pub.dartlang.org" 407 | source: hosted 408 | version: "1.8.2" 409 | stack_trace: 410 | dependency: transitive 411 | description: 412 | name: stack_trace 413 | url: "https://pub.dartlang.org" 414 | source: hosted 415 | version: "1.10.0" 416 | stream_channel: 417 | dependency: transitive 418 | description: 419 | name: stream_channel 420 | url: "https://pub.dartlang.org" 421 | source: hosted 422 | version: "2.1.0" 423 | string_scanner: 424 | dependency: transitive 425 | description: 426 | name: string_scanner 427 | url: "https://pub.dartlang.org" 428 | source: hosted 429 | version: "1.1.0" 430 | term_glyph: 431 | dependency: transitive 432 | description: 433 | name: term_glyph 434 | url: "https://pub.dartlang.org" 435 | source: hosted 436 | version: "1.2.0" 437 | test_api: 438 | dependency: transitive 439 | description: 440 | name: test_api 441 | url: "https://pub.dartlang.org" 442 | source: hosted 443 | version: "0.4.9" 444 | transparent_image: 445 | dependency: "direct main" 446 | description: 447 | name: transparent_image 448 | url: "https://pub.dartlang.org" 449 | source: hosted 450 | version: "2.0.0" 451 | typed_data: 452 | dependency: transitive 453 | description: 454 | name: typed_data 455 | url: "https://pub.dartlang.org" 456 | source: hosted 457 | version: "1.3.0" 458 | universal_io: 459 | dependency: transitive 460 | description: 461 | name: universal_io 462 | url: "https://pub.dartlang.org" 463 | source: hosted 464 | version: "2.0.4" 465 | vector_math: 466 | dependency: transitive 467 | description: 468 | name: vector_math 469 | url: "https://pub.dartlang.org" 470 | source: hosted 471 | version: "2.1.2" 472 | win32: 473 | dependency: transitive 474 | description: 475 | name: win32 476 | url: "https://pub.dartlang.org" 477 | source: hosted 478 | version: "2.5.2" 479 | xdg_directories: 480 | dependency: transitive 481 | description: 482 | name: xdg_directories 483 | url: "https://pub.dartlang.org" 484 | source: hosted 485 | version: "0.2.0+1" 486 | xml: 487 | dependency: "direct main" 488 | description: 489 | name: xml 490 | url: "https://pub.dartlang.org" 491 | source: hosted 492 | version: "5.4.1" 493 | sdks: 494 | dart: ">=2.17.0 <3.0.0" 495 | flutter: ">=2.8.0" 496 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: remote_control_for_vlc 2 | description: Remote Control for VLC 3 | 4 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 5 | 6 | version: 1.5.0+15 7 | 8 | environment: 9 | sdk: ">=2.17.0 <3.0.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | connectivity_plus: ^2.3.0 15 | http: ^0.13.4 16 | just_throttle_it: ^2.1.0 17 | network_info_plus: ^2.1.3 18 | network_tools: ^1.0.8 19 | shared_preferences: ^2.0.15 20 | transparent_image: ^2.0.0 21 | xml: ^5.4.1 22 | 23 | dev_dependencies: 24 | flutter_test: 25 | sdk: flutter 26 | 27 | # The "flutter_lints" package below contains a set of recommended lints to 28 | # encourage good coding practices. The lint set provided by the package is 29 | # activated in the `analysis_options.yaml` file located at the root of your 30 | # package. See that file for information about deactivating specific lint 31 | # rules and activating additional ones. 32 | flutter_lints: ^2.0.0 33 | 34 | # For information on the generic Dart part of this file, see the 35 | # following page: https://dart.dev/tools/pub/pubspec 36 | 37 | flutter: 38 | uses-material-design: true 39 | 40 | assets: 41 | - assets/icon-512.png 42 | - assets/cone.png 43 | - assets/linux-ifconfig.png 44 | - assets/linux-lua.png 45 | - assets/linux-main-interface.png 46 | - assets/linux-menu.png 47 | - assets/linux-password.png 48 | - assets/linux-show-settings.png 49 | - assets/linux-terminal.png 50 | - assets/linux-web.png 51 | - assets/mac-http-interface.png 52 | - assets/mac-ip.png 53 | - assets/mac-menu.png 54 | - assets/mac-network.png 55 | - assets/signal-0.png 56 | - assets/signal-1.png 57 | - assets/signal-2.png 58 | - assets/signal-3.png 59 | - assets/windows-cmd.png 60 | - assets/windows-ipconfig.png 61 | - assets/windows-lua.png 62 | - assets/windows-main-interface.png 63 | - assets/windows-menu.png 64 | - assets/windows-password.png 65 | - assets/windows-run.png 66 | - assets/windows-show-settings.png 67 | - assets/windows-web.png 68 | -------------------------------------------------------------------------------- /screenshots/equalizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/equalizer.png -------------------------------------------------------------------------------- /screenshots/file-browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/file-browser.png -------------------------------------------------------------------------------- /screenshots/host-ip-guide-os.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/host-ip-guide-os.png -------------------------------------------------------------------------------- /screenshots/open-media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/open-media.png -------------------------------------------------------------------------------- /screenshots/playback-speed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/playback-speed.png -------------------------------------------------------------------------------- /screenshots/playing-menu-vlc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/playing-menu-vlc.png -------------------------------------------------------------------------------- /screenshots/playing-vlc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/playing-vlc.png -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/settings.png -------------------------------------------------------------------------------- /screenshots/setup-guide-os.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/setup-guide-os.png -------------------------------------------------------------------------------- /screenshots/setup-guide-steps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/setup-guide-steps.png -------------------------------------------------------------------------------- /screenshots/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/setup.png -------------------------------------------------------------------------------- /screenshots/vlc-connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/remote_control_for_vlc/ec2fcee9e023ccdfbf43e5807612d73f3e39727d/screenshots/vlc-connected.png --------------------------------------------------------------------------------