diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..e9a4fc2
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,76 @@
+name: Build and Release
+
+on:
+ push:
+ tags:
+ - 'v*' # Trigger on any tag starting with 'v', like v1.0, v2.3.1, etc.
+
+jobs:
+ build:
+ name: Build and Release
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up Python 3.12.6
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.12.6'
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+
+ - name: Get version from tag
+ id: get_version
+ run: |
+ $VERSION = "${{ github.ref_name }}"
+ $VERSION = $VERSION -replace "^v", ""
+ echo "version=$VERSION" >> $env:GITHUB_OUTPUT
+ shell: pwsh
+
+ - name: Build application
+ run: |
+ python build.py build
+
+ - name: Build installer
+ run: |
+ python build.py installer
+
+ - name: Create release package
+ run: |
+ python build.py package
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v3
+ with:
+ name: release-artifacts
+ path: |
+ dist/VLC Discord Presence.exe
+ dist/VLC Discord RP Setup.exe
+ VLC_Discord_RP.zip
+
+ - name: Create Release
+ id: create_release
+ uses: softprops/action-gh-release@v1
+ with:
+ name: VLC Discord Rich Presence ${{ steps.get_version.outputs.version }}
+ body: |
+ # VLC Discord Rich Presence ${{ steps.get_version.outputs.version }}
+
+ ## Installation
+ Download and run the installer (`VLC Discord RP Setup.exe`), or use the standalone application.
+
+ ## Requirements
+ - Windows
+ - VLC Media Player
+ - Discord
+ files: |
+ dist/VLC Discord RP Setup.exe
+ VLC_Discord_RP.zip
+ draft: false
+ prerelease: false
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ced7fb2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,52 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+*.manifest
+*.spec
+!vlc_discord_rp.spec
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# IDE
+.idea/
+.vscode/*
+!.vscode/settings.json
+.DS_Store
+
+# Windows
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
+
+# Project specific
+release/
+VLC_Discord_RP.zip
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..e99b4e2
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,23 @@
+{
+
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "always"
+ },
+ "python.analysis.extraPaths": [
+ "${workspaceFolder}"
+ ],
+ "[python]": {
+ "editor.defaultFormatter": "ms-python.black-formatter",
+ "editor.tabSize": 4,
+ "editor.insertSpaces": true
+ },
+ "files.exclude": {
+ "**/__pycache__": true,
+ "**/.pytest_cache": true,
+ "**/*.pyc": true
+ },
+ "terminal.integrated.env.windows": {
+ "PYTHONPATH": "${workspaceFolder}"
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b1c08cc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Valentin Marquez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..48f4355
--- /dev/null
+++ b/README.md
@@ -0,0 +1,96 @@
+# VLC Discord Rich Presence
+
+

+
+
+> *"Because your friends totally need to know you're watching Shrek for the 17th time."*
+
+## What Is This Thing?
+
+VLC Discord Rich Presence is a magical little bridge between your VLC Media Player and Discord, letting your friends see what media you're currently enjoying (or enduring). It shows:
+
+- What you're watching/listening to
+- Artist and album info (for music)
+- Play/pause status
+- Fancy icons and progress bars
+
+Built during a questionable 10-hour coding marathon fueled by caffeine and poor life decisions (1AM-11AM), this app works surprisingly well despite its sleep-deprived origins!
+
+## Installation
+
+### The Easy Way
+
+1. Download the latest installer from the [releases page](https://github.com/valentin-marquez/vlc-discord-rp/releases)
+2. Run the installer (`VLC Discord RP Setup.exe`)
+3. Follow the on-screen instructions
+4. Click "Install Now"
+5. Let the installer do its magic ✨
+6. Enjoy your new Discord flex powers!
+
+### The Hard Way (For Developers and Masochists)
+
+1. Clone this repo
+2. Install requirements: `pip install -r requirements.txt`
+3. Run `python build.py all` to build both the app and installer
+4. Look in the `dist` folder for your shiny new executables
+
+## How It Works
+
+1. The extension adds a Lua script to VLC that tracks what you're playing
+2. This script creates and continuously updates a file at `%APPDATA%\vlc\vlc_discord_status.json` with your current media information
+3. The background application reads this JSON file and relays the information to Discord's Rich Presence API
+4. Your friends wonder why you're watching cooking videos at 3 AM
+5. Social validation achieved! 🎉
+
+## FAQ
+
+### Q: Why isn't anything showing up in Discord?
+**A:** Make sure:
+1. Both VLC and Discord are running
+2. The extension is activated in VLC (View -> Discord RP)
+3. You're playing a media file
+4. The app was added to Windows startup during installation (it needs to be running to work)
+5. Due to VLC limitations, you may need to re-enable the extension when you first open VLC (not needed when switching media files while VLC remains open)
+6. If it's enabled but still not showing, try forcing an update by clicking View -> Discord RP -> Update Now
+
+You can check the VLC console (Ctrl+M) for any errors. If issues persist, please report them in the GitHub Issues section.
+
+### Q: How do I close/exit the app?
+**A:** The app runs silently in the background. To close it, find `VLC Discord Presence.exe` in Task Manager and end the process. Yes, I know, very user-friendly. I was sleepy, okay?
+
+### Q: Where is it installed?
+**A:** By default, the app installs to `%LOCALAPPDATA%\VLC Discord RP`. The Lua extension goes to `%APPDATA%\vlc\lua\extensions`.
+
+### Q: Does it work on Mac/Linux?
+**A:** Currently tested on Windows only. Could work elsewhere with manual installation, but you're on your own there, brave explorer.
+
+### Q: How much of my privacy am I giving up?
+**A:** Only the title, artist, and album info from your media files are shared - and only with your Discord friends. We don't collect any data. Also, you can pause the sharing by closing VLC.
+
+### Q: I found a bug!
+**A:** That's not a question, but I respect your enthusiasm. Please open an issue on GitHub with details about what went wrong.
+
+### Q: Why did you make this?
+**A:** The voices told me to. Also, it seemed like a fun project.
+
+
+
+## Development Notes
+
+This project was created in one sleepless night from 1AM to 11AM, which explains:
+- Some questionable code decisions
+- The lack of proper error handling in places
+- Why the uninstaller is a batch file (it just works™)
+- The dependency on Task Manager to close it
+
+Pull requests are welcome if you want to improve things while I catch up on sleep!
+
+## License
+
+MIT License - Feel free to use, modify, and distribute as you see fit.
+
+---
+
+*Made with ❤️ and sleep deprivation*
+
+*"It works on my machine!"* - Developer motto
\ No newline at end of file
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..2a65822
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/vlc_ios.png b/assets/vlc_ios.png
new file mode 100644
index 0000000..b55b8b8
Binary files /dev/null and b/assets/vlc_ios.png differ
diff --git a/build.py b/build.py
new file mode 100644
index 0000000..0332eae
--- /dev/null
+++ b/build.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+import argparse
+import os
+import shutil
+import subprocess
+
+
+def clean():
+ """Clean build artifacts"""
+ print("Cleaning build directories...")
+ dirs_to_clean = ["build", "dist", "__pycache__", "release", "VLC_Discord_RP"]
+
+ for dir_name in dirs_to_clean:
+ if os.path.exists(dir_name):
+ shutil.rmtree(dir_name)
+
+ # Clean .pyc files
+ for root, dirs, files in os.walk("."):
+ for file in files:
+ if file.endswith(".pyc"):
+ os.remove(os.path.join(root, file))
+
+ # Remove zip file if it exists
+ zip_file = "VLC_Discord_RP.zip"
+ if os.path.exists(zip_file):
+ os.remove(zip_file)
+ print(f"Removed {zip_file}")
+
+ print("Clean completed")
+
+
+def build_app():
+ """Build the VLC Discord RP application executable"""
+ print("Building VLC Discord Rich Presence application...")
+
+ # Run PyInstaller for the main application
+ subprocess.run(["pyinstaller", "--clean", "vlc_discord_rp.spec"], check=True)
+
+ print("Application build complete!")
+
+
+def build_installer():
+ """Build the installer executable"""
+ print("Building installer...")
+
+ # First make sure the application was built
+ if not os.path.exists(os.path.join("dist", "VLC Discord Presence.exe")):
+ print("Error: Application executable not found. Run build first.")
+ return False
+
+ # Create installer spec
+ installer_spec = """# -*- mode: python -*-
+a = Analysis(
+ ['scripts/installer.py'],
+ pathex=[],
+ binaries=[],
+ datas=[
+ ('dist/VLC Discord Presence.exe', '.'),
+ ('assets', 'assets'),
+ ('lua', 'lua'),
+ ],
+ hiddenimports=[],
+ hookspath=[],
+ runtime_hooks=[],
+ excludes=[],
+ win_no_prefer_redirects=False,
+ win_private_assemblies=False,
+ noarchive=False
+)
+
+pyz = PYZ(a.pure, a.zipped_data)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [],
+ name='VLC Discord RP Setup',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ runtime_tmpdir=None,
+ console=False,
+ disable_windowed_traceback=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon='assets/icon.ico',
+)
+"""
+
+ # Write the installer spec file
+ with open("installer.spec", "w") as spec_file:
+ spec_file.write(installer_spec)
+
+ # Run PyInstaller for the installer
+ subprocess.run(["pyinstaller", "--clean", "installer.spec"], check=True)
+
+ # Cleanup
+ os.remove("installer.spec")
+
+ print("Installer build complete!")
+ return True
+
+
+def package():
+ """Package everything into a release zip"""
+ print("Creating release package...")
+
+ # Create release directory
+ release_dir = "release"
+ if os.path.exists(release_dir):
+ shutil.rmtree(release_dir)
+ os.makedirs(release_dir)
+
+ # Copy installer
+ installer_path = os.path.join("dist", "VLC Discord RP Setup.exe")
+ if os.path.exists(installer_path):
+ shutil.copy2(
+ installer_path, os.path.join(release_dir, "VLC Discord RP Setup.exe")
+ )
+ else:
+ print("Warning: Installer executable not found.")
+
+ # Copy readme and license
+ if os.path.exists("README.md"):
+ shutil.copy2("README.md", os.path.join(release_dir, "README.md"))
+
+ if os.path.exists("LICENSE"):
+ shutil.copy2("LICENSE", os.path.join(release_dir, "LICENSE"))
+
+ # Create ZIP archive
+ shutil.make_archive("VLC_Discord_RP", "zip", release_dir)
+ print("Release package created: VLC_Discord_RP.zip")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Build VLC Discord Rich Presence")
+ parser.add_argument(
+ "command",
+ choices=["clean", "build", "installer", "package", "all"],
+ help="Build command to run",
+ )
+
+ args = parser.parse_args()
+
+ if args.command == "clean":
+ clean()
+ elif args.command == "build":
+ build_app()
+ elif args.command == "installer":
+ build_installer()
+ elif args.command == "package":
+ package()
+ elif args.command == "all":
+ clean()
+ build_app()
+ if build_installer():
+ package()
+ else:
+ parser.print_help()
diff --git a/lua/discord-rp.lua b/lua/discord-rp.lua
new file mode 100644
index 0000000..bcfd95f
--- /dev/null
+++ b/lua/discord-rp.lua
@@ -0,0 +1,323 @@
+local presence_active = false
+local status_file_path = nil
+local debug_mode = true
+
+function debug_log(message)
+ if debug_mode then
+ vlc.msg.info("Discord RP Debug: " .. tostring(message))
+ end
+end
+
+function descriptor()
+ return {
+ title = "Discord Rich Presence",
+ version = "1.1.0",
+ author = "Valentin Marquez",
+ url = 'https://github.com/valentin-marquez/vlc-discord-rp',
+ shortdesc = "Discord RP",
+ description = "Display your currently playing media in Discord Rich Presence",
+ capabilities = {"menu","input-listener", "meta-listener", "playing-listener", "playlist-listener"}
+ }
+end
+
+function setup_status_file()
+ debug_log("Setting up status file")
+
+ local cache_dir = ""
+ local ok, dir = pcall(function()
+ return vlc.config.cachedir()
+ end)
+
+ if ok and dir then
+ cache_dir = dir
+ else
+ ok, dir = pcall(function()
+ return vlc.config.userdatadir()
+ end)
+ if ok and dir then
+ cache_dir = dir
+ else
+ if string.match(package.config, "\\") then
+ cache_dir = os.getenv("TEMP") or "C:\\Temp"
+ else
+ cache_dir = "/tmp"
+ end
+ end
+ end
+
+ if not string.match(cache_dir, "[/\\]$") then
+ cache_dir = cache_dir .. "/"
+ end
+
+ cache_dir = string.gsub(cache_dir, "\\", "/")
+
+ status_file_path = cache_dir .. "vlc_discord_status.json"
+ vlc.msg.info("Discord RP: Status file at " .. status_file_path)
+
+ local test_ok, test_err = pcall(function()
+ local file = io.open(status_file_path, "w")
+ if not file then
+ return false, "Could not open file for writing"
+ end
+ file:write("{\"test\": true}")
+ file:close()
+ return true
+ end)
+
+ if not test_ok then
+ vlc.msg.err("Discord RP: Error testing file write: " .. tostring(test_err))
+ return false
+ end
+
+ return true
+end
+
+function activate()
+ vlc.msg.info("Discord RP: Attempting to activate")
+
+ local vlc_version
+ pcall(function()
+ if vlc.misc and vlc.misc.version then
+ vlc_version = vlc.misc.version()
+ else
+ vlc_version = "unknown"
+ end
+ end)
+ vlc.msg.info("Discord RP: Running on VLC version " .. tostring(vlc_version))
+
+ local setup_ok, setup_err = pcall(setup_status_file)
+ if not setup_ok then
+ vlc.msg.err("Discord RP: Error in setup_status_file: " .. tostring(setup_err))
+ return false
+ end
+
+ presence_active = true
+
+ local write_ok, write_err = pcall(write_status, {
+ active = true,
+ status = "idle",
+ timestamp = os.time()
+ })
+
+ if not write_ok then
+ vlc.msg.err("Discord RP: Error writing initial status: " .. tostring(write_err))
+ return false
+ end
+
+ vlc.msg.info("Discord RP: Successfully activated")
+ return true
+end
+
+function deactivate()
+ vlc.msg.info("Discord RP deactivated")
+ presence_active = false
+
+ pcall(write_status, {
+ active = false,
+ timestamp = os.time()
+ })
+end
+
+function menu()
+ return {"Update now", "About"}
+end
+
+function trigger_menu(id)
+ if id == 1 then
+ update_presence()
+ elseif id == 2 then
+ show_credits()
+ end
+end
+
+function show_credits()
+ local d = vlc.dialog("About")
+ d:add_label("Discord Rich Presence for VLC", 1, 1, 3, 1)
+ d:add_label("by Valentin Marquez", 1, 2, 3, 1)
+ d:add_label("Version 1.1.0", 1, 2, 3, 1)
+ d:show()
+end
+
+function write_status(status)
+ if not status_file_path then
+ vlc.msg.err("Discord RP: Status file path not defined")
+ return false
+ end
+
+ debug_log("Writing status: " .. tostring(status.status or "nil"))
+
+ local file, err = io.open(status_file_path, "w")
+ if not file then
+ vlc.msg.err("Discord RP: Error opening file: " .. tostring(err))
+ return false
+ end
+
+ local function table_to_json(t, indent)
+ indent = indent or 0
+ local spaces = string.rep(" ", indent)
+ local json = "{\n"
+
+ local keys = {}
+ for k in pairs(t) do
+ table.insert(keys, k)
+ end
+
+ for i, k in ipairs(keys) do
+ local v = t[k]
+ json = json .. spaces .. " \"" .. k .. "\": "
+
+ if type(v) == "table" then
+ json = json .. table_to_json(v, indent + 1)
+ elseif type(v) == "string" then
+ json = json .. "\"" .. v:gsub('"', '\\"') .. "\""
+ elseif type(v) == "number" or type(v) == "boolean" then
+ json = json .. tostring(v)
+ else
+ json = json .. "\"" .. tostring(v) .. "\""
+ end
+
+ if i < #keys then
+ json = json .. ",\n"
+ else
+ json = json .. "\n"
+ end
+ end
+
+ if json:sub(-2) == ",\n" then
+ json = json:sub(1, -3) .. "\n"
+ end
+
+ return json .. spaces .. "}"
+ end
+
+ local write_ok, write_err = pcall(function()
+ file:write(table_to_json(status))
+ file:close()
+ end)
+
+ if not write_ok then
+ vlc.msg.err("Discord RP: Error writing: " .. tostring(write_err))
+ return false
+ end
+
+ return true
+end
+
+function update_presence()
+ debug_log("Updating presence")
+
+ if not presence_active then
+ return
+ end
+
+ if not vlc.input.is_playing() then
+ write_status({
+ active = false,
+ status = "stopped",
+ timestamp = os.time()
+ })
+ return
+ end
+
+ local input_item = nil
+ local success = pcall(function()
+ input_item = vlc.input.item()
+ input_item = vlc.input.item()
+ end)
+
+ if not success or not input_item then
+ debug_log("Could not retrieve playback item")
+ write_status({
+ active = true,
+ status = "idle",
+ timestamp = os.time()
+ })
+ return
+ end
+
+ local title = input_item:name() or "Unknown"
+ local metas = input_item:metas() or {}
+ local artist = metas["artist"] or ""
+ local album = metas["album"] or ""
+
+ local duration = input_item:duration() or 0
+
+ local input = vlc.object.input()
+ local is_playing = true
+ local position = 0
+ local rate = 1.0
+ local audio_delay = 0
+
+ if input then
+ local state = vlc.var.get(input, "state")
+ is_playing = (state == 2)
+
+ position = math.floor(vlc.var.get(input, "time") / 1000000)
+ rate = vlc.var.get(input, "rate")
+ audio_delay = vlc.var.get(input, "audio-delay") / 1000000
+ end
+
+ local now_playing = {
+ title = title,
+ artist = artist,
+ album = album,
+ genre = metas["genre"] or "",
+ track_number = metas["track_number"] or "",
+ date = metas["date"] or ""
+ }
+
+ local format = ""
+ local info = input_item:info() or {}
+ if info["General"] then
+ for k, v in pairs(info["General"]) do
+ if k:lower():match("format") then
+ format = v
+ break
+ end
+ end
+ end
+
+ local status = {
+ active = true,
+ status = is_playing and "playing" or "paused",
+ media = {
+ title = title,
+ artist = artist,
+ album = album,
+ genre = now_playing.genre,
+ track_number = now_playing.track_number,
+ year = now_playing.date,
+ format = format
+ },
+ playback = {
+ duration = math.floor(duration),
+ position = position,
+ remaining = math.floor(duration) - position,
+ rate = rate,
+ audio_delay = audio_delay
+ },
+ timestamp = os.time()
+ }
+
+ write_status(status)
+end
+
+function input_changed()
+ debug_log("input_changed event")
+ update_presence()
+end
+
+function meta_changed()
+ debug_log("meta_changed event")
+ update_presence()
+end
+
+function playing_changed()
+ debug_log("playing_changed event")
+ update_presence()
+end
+
+function playlist_changed()
+ debug_log("playlist_changed event")
+ update_presence()
+end
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..1cec6f9
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pypresence @ git+https://github.com/qwertyquerty/pypresence.git
+pyinstaller==6.1.0
\ No newline at end of file
diff --git a/scripts/installer.py b/scripts/installer.py
new file mode 100644
index 0000000..7f1f8c8
--- /dev/null
+++ b/scripts/installer.py
@@ -0,0 +1,416 @@
+import ctypes
+import os
+import shutil
+import subprocess
+import sys
+import tkinter as tk
+import winreg
+from tkinter import filedialog, messagebox, ttk
+
+
+def is_admin():
+ """Check if the script is running with admin privileges"""
+ try:
+ return ctypes.windll.shell32.IsUserAnAdmin()
+ except Exception:
+ return False
+
+
+def resource_path(relative_path):
+ """Get absolute path to resource, works for dev and PyInstaller"""
+ base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
+ return os.path.join(base_path, relative_path)
+
+
+def find_vlc_path():
+ """Find VLC installation path from registry"""
+ try:
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\VideoLAN\VLC") as key:
+ return winreg.QueryValueEx(key, "InstallDir")[0]
+ except WindowsError:
+ # Try 32-bit registry view on 64-bit Windows
+ try:
+ with winreg.OpenKey(
+ winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\VideoLAN\VLC"
+ ) as key:
+ return winreg.QueryValueEx(key, "InstallDir")[0]
+ except WindowsError:
+ pass
+
+ # Try common paths
+ common_paths = [
+ r"C:\Program Files\VideoLAN\VLC",
+ r"C:\Program Files (x86)\VideoLAN\VLC",
+ ]
+
+ for path in common_paths:
+ if os.path.exists(path):
+ return path
+
+ return None
+
+
+def get_extension_dir():
+ """Get VLC extension directory"""
+
+ appdata_path = os.path.join(
+ os.environ.get("APPDATA", ""), "vlc", "lua", "extensions"
+ )
+
+ if not os.path.exists(appdata_path):
+ os.makedirs(appdata_path, exist_ok=True)
+
+ return appdata_path
+
+
+def add_to_startup(exe_path):
+ """Add to Windows startup"""
+ startup_folder = os.path.join(
+ os.environ["APPDATA"], r"Microsoft\Windows\Start Menu\Programs\Startup"
+ )
+
+ shortcut_path = os.path.join(startup_folder, "VLC Discord Presence.lnk")
+
+ ps_script = f"""
+ $WshShell = New-Object -ComObject WScript.Shell
+ $Shortcut = $WshShell.CreateShortcut("{shortcut_path}")
+ $Shortcut.TargetPath = "{exe_path}"
+ $Shortcut.WorkingDirectory = "{os.path.dirname(exe_path)}"
+ $Shortcut.Description = "VLC Discord Rich Presence"
+ $Shortcut.IconLocation = "{exe_path},0"
+ $Shortcut.Save()
+ """
+
+ subprocess.run(
+ ["powershell", "-Command", ps_script], capture_output=True, text=True
+ )
+
+ return os.path.exists(shortcut_path)
+
+
+def create_uninstaller(
+ install_dir, extension_path, exe_name="VLC Discord Presence.exe"
+):
+ """Create an uninstaller batch file"""
+ uninstall_script = os.path.join(install_dir, "uninstall.bat")
+ startup_shortcut = os.path.join(
+ os.environ["APPDATA"],
+ r"Microsoft\Windows\Start Menu\Programs\Startup",
+ "VLC Discord Presence.lnk",
+ )
+
+ with open(uninstall_script, "w") as f:
+ f.write("@echo off\n")
+ f.write("echo Uninstalling VLC Discord Rich Presence...\n")
+ f.write('taskkill /f /im "%s" >nul 2>&1\n' % exe_name)
+ f.write("timeout /t 1 /nobreak >nul\n")
+ f.write('del "%s" >nul 2>&1\n' % startup_shortcut)
+ f.write('del "%s" >nul 2>&1\n' % extension_path)
+ f.write('rmdir /s /q "%s" >nul 2>&1\n' % install_dir)
+ f.write("echo Uninstallation complete\n")
+ f.write("pause\n")
+
+ f.write('(goto) 2>nul & del "%~f0"\n')
+
+ return uninstall_script
+
+
+class InstallerGUI:
+ def __init__(self):
+ self.root = tk.Tk()
+ self.root.title("VLC Discord Rich Presence Installer")
+ self.root.geometry("600x500")
+ self.root.resizable(False, False)
+
+ try:
+ icon_path = resource_path(os.path.join("assets", "icon.ico"))
+ if os.path.exists(icon_path):
+ self.root.iconbitmap(icon_path)
+ except Exception as e:
+ print(f"Error setting icon: {e}")
+
+ self.style = ttk.Style()
+ self.style.configure("TButton", padding=6, relief="flat", font=("Segoe UI", 10))
+ self.style.configure("TLabel", font=("Segoe UI", 10))
+ self.style.configure("Header.TLabel", font=("Segoe UI", 14, "bold"))
+
+ self.style.configure(
+ "Action.TButton", padding=10, font=("Segoe UI", 10, "bold")
+ )
+
+ self.create_widgets()
+
+ self.root.update_idletasks()
+ width = self.root.winfo_width()
+ height = self.root.winfo_height()
+ x = (self.root.winfo_screenwidth() // 2) - (width // 2)
+ y = (self.root.winfo_screenheight() // 2) - (height // 2)
+ self.root.geometry(f"{width}x{height}+{x}+{y}")
+
+ self.root.update_idletasks()
+
+ def create_widgets(self):
+ """Create GUI widgets"""
+
+ main_frame = ttk.Frame(self.root)
+ main_frame.pack(fill="both", expand=True)
+
+ header_frame = ttk.Frame(main_frame, padding="20 20 20 10")
+ header_frame.pack(fill="x")
+
+ ttk.Label(
+ header_frame, text="VLC Discord Rich Presence", style="Header.TLabel"
+ ).pack(side="left")
+
+ ttk.Label(
+ header_frame,
+ text="This wizard will install VLC Discord Rich Presence on your computer.",
+ wraplength=550,
+ ).pack(side="top", anchor="w", pady=(25, 0))
+
+ dir_frame = ttk.LabelFrame(
+ main_frame, text="Installation Directory", padding="20 10"
+ )
+ dir_frame.pack(fill="x", padx=20)
+
+ self.install_dir = tk.StringVar()
+ self.install_dir.set(
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), "VLC Discord RP")
+ )
+
+ dir_entry = ttk.Entry(dir_frame, textvariable=self.install_dir, width=50)
+ dir_entry.pack(side="left", padx=(0, 10), fill="x", expand=True)
+
+ dir_btn = ttk.Button(
+ dir_frame, text="Browse...", command=self.browse_install_dir
+ )
+ dir_btn.pack(side="right")
+
+ vlc_frame = ttk.LabelFrame(main_frame, text="VLC Installation", padding="20 10")
+ vlc_frame.pack(fill="x", padx=20, pady=(20, 0))
+
+ self.vlc_dir = tk.StringVar()
+ vlc_path = find_vlc_path()
+ if vlc_path:
+ self.vlc_dir.set(vlc_path)
+
+ vlc_entry = ttk.Entry(vlc_frame, textvariable=self.vlc_dir, width=50)
+ vlc_entry.pack(side="left", padx=(0, 10), fill="x", expand=True)
+
+ vlc_btn = ttk.Button(vlc_frame, text="Browse...", command=self.browse_vlc_dir)
+ vlc_btn.pack(side="right")
+
+ options_frame = ttk.LabelFrame(main_frame, text="Options", padding="20 10")
+ options_frame.pack(fill="x", padx=20, pady=(20, 0))
+
+ startup_frame = ttk.Frame(options_frame)
+ startup_frame.pack(fill="x", anchor="w")
+
+ self.add_startup = tk.BooleanVar(value=True)
+ startup_cb = ttk.Checkbutton(
+ startup_frame, text="Add to Windows startup", variable=self.add_startup
+ )
+ startup_cb.pack(side="left")
+
+ ttk.Label(
+ startup_frame,
+ text="(Recommended - Required for automatic start with VLC)",
+ foreground="#0066CC",
+ font=("Segoe UI", 9, "italic"),
+ ).pack(side="left", padx=(5, 0))
+
+ self.create_shortcut = tk.BooleanVar(value=True)
+ ttk.Checkbutton(
+ options_frame, text="Create desktop shortcut", variable=self.create_shortcut
+ ).pack(anchor="w")
+
+ self.status_var = tk.StringVar()
+ self.status_var.set("Ready to install")
+
+ status_frame = ttk.Frame(main_frame, padding="20 10")
+ status_frame.pack(fill="x", padx=20, pady=(20, 0))
+
+ ttk.Label(status_frame, textvariable=self.status_var, foreground="gray").pack(
+ anchor="w"
+ )
+
+ self.progress = ttk.Progressbar(main_frame, length=560, mode="determinate")
+ self.progress.pack(pady=(10, 0), padx=20)
+
+ btn_frame = ttk.Frame(main_frame, padding="20")
+ btn_frame.pack(fill="x", side="bottom", pady=(20, 0))
+
+ install_btn = ttk.Button(
+ btn_frame, text="Install Now", command=self.install, style="Action.TButton"
+ )
+ install_btn.pack(side="right")
+
+ ttk.Button(btn_frame, text="Cancel", command=self.root.destroy).pack(
+ side="right", padx=10
+ )
+
+ def browse_install_dir(self):
+ directory = filedialog.askdirectory(
+ initialdir=self.install_dir.get(), title="Select Installation Directory"
+ )
+ if directory:
+ self.install_dir.set(directory)
+
+ def browse_vlc_dir(self):
+ directory = filedialog.askdirectory(
+ initialdir=self.vlc_dir.get() if self.vlc_dir.get() else "/",
+ title="Select VLC Installation Directory",
+ )
+ if directory:
+ self.vlc_dir.set(directory)
+
+ def update_progress(self, value, message):
+ self.progress["value"] = value
+ self.status_var.set(message)
+ self.root.update_idletasks()
+
+ def install(self):
+
+ install_dir = self.install_dir.get()
+ vlc_dir = self.vlc_dir.get()
+
+ if not os.path.exists(vlc_dir):
+ messagebox.showerror("Error", "VLC installation directory not found.")
+ return
+
+ try:
+
+ self.update_progress(10, "Creating installation directory...")
+ os.makedirs(install_dir, exist_ok=True)
+
+ self.update_progress(20, "Installing main application...")
+ exe_path = os.path.join(install_dir, "VLC Discord Presence.exe")
+
+ bundled_exe = resource_path("VLC Discord Presence.exe")
+ if os.path.exists(bundled_exe):
+
+ shutil.copy2(bundled_exe, exe_path)
+ else:
+
+ shutil.copy2(sys.executable, exe_path)
+
+ self.update_progress(30, "Installing assets...")
+ assets_dir = os.path.join(install_dir, "assets")
+ os.makedirs(assets_dir, exist_ok=True)
+
+ assets_source = resource_path("assets")
+ if os.path.isdir(assets_source):
+ for file in os.listdir(assets_source):
+ src_file = os.path.join(assets_source, file)
+ dst_file = os.path.join(assets_dir, file)
+ if os.path.isfile(src_file):
+ shutil.copy2(src_file, dst_file)
+
+ self.update_progress(50, "Installing VLC extension...")
+ extension_dir = get_extension_dir()
+ lua_source = resource_path(os.path.join("lua", "discord-rp.lua"))
+ extension_path = os.path.join(extension_dir, "discord-rp.lua")
+
+ shutil.copy2(lua_source, extension_path)
+
+ if self.add_startup.get():
+ self.update_progress(70, "Adding to startup...")
+ if not add_to_startup(exe_path):
+ messagebox.showwarning(
+ "Warning",
+ "Could not add to startup. You may need to do this manually.",
+ )
+
+ if self.create_shortcut.get():
+ self.update_progress(80, "Creating desktop shortcut...")
+
+ # Use Windows shell special folders to reliably get desktop path
+ # This works with OneDrive redirection and localized folder names
+ ps_get_desktop = """
+ [Environment]::GetFolderPath("Desktop")
+ """
+
+ try:
+
+ desktop_result = subprocess.run(
+ ["powershell", "-Command", ps_get_desktop],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ desktop_path = desktop_result.stdout.strip()
+
+ if desktop_path and os.path.exists(desktop_path):
+ shortcut_path = os.path.join(
+ desktop_path, "VLC Discord Presence.lnk"
+ )
+
+ ps_script = f"""
+ $WshShell = New-Object -ComObject WScript.Shell
+ $Shortcut = $WshShell.CreateShortcut("{shortcut_path}")
+ $Shortcut.TargetPath = "{exe_path}"
+ $Shortcut.WorkingDirectory = "{install_dir}"
+ $Shortcut.Description = "VLC Discord Rich Presence"
+ $Shortcut.IconLocation = "{exe_path},0"
+ $Shortcut.Save()
+ """
+
+ result = subprocess.run(
+ ["powershell", "-Command", ps_script],
+ capture_output=True,
+ text=True,
+ )
+
+ if not os.path.exists(shortcut_path):
+ raise Exception(
+ f"Failed to create shortcut: {result.stderr}"
+ )
+ else:
+ raise Exception(f"Invalid desktop path: {desktop_path}")
+
+ except Exception as e:
+
+ print(f"Error creating desktop shortcut: {e}")
+ messagebox.showwarning(
+ "Warning",
+ "Could not create desktop shortcut. You may need to do this manually.",
+ )
+
+ self.update_progress(90, "Creating uninstaller...")
+ create_uninstaller(install_dir, extension_path)
+
+ self.update_progress(95, "Starting application...")
+ subprocess.Popen([exe_path], cwd=install_dir)
+
+ self.update_progress(100, "Installation complete!")
+
+ messagebox.showinfo(
+ "Installation Complete",
+ "VLC Discord Rich Presence has been installed successfully!\n\n"
+ "The application is now running in the background.",
+ )
+
+ self.root.destroy()
+
+ except Exception as e:
+ messagebox.showerror(
+ "Error", f"An error occurred during installation:\n\n{str(e)}"
+ )
+ self.update_progress(0, "Installation failed.")
+
+ def run(self):
+
+ self.root.update_idletasks()
+
+ required_height = self.root.winfo_reqheight()
+ if required_height > self.root.winfo_height():
+ new_height = required_height + 20
+ self.root.geometry(f"{self.root.winfo_width()}x{new_height}")
+
+ self.root.mainloop()
+
+
+if __name__ == "__main__":
+ app = InstallerGUI()
+ app.run()
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/discord_handler.py b/src/discord_handler.py
new file mode 100644
index 0000000..a57f6e1
--- /dev/null
+++ b/src/discord_handler.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""
+VLC Discord Rich Presence Handler
+A companion for the VLC Discord RP Lua script
+"""
+
+import json
+import logging
+import os
+import sys
+import time
+
+import pypresence
+from pypresence import ActivityType
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ handlers=[logging.StreamHandler(), logging.FileHandler("vlc_discord_rp.log")],
+)
+logger = logging.getLogger("VLC-Discord-RP")
+
+CLIENT_ID = "1345358480671772683"
+
+LARGE_IMAGE = "logo"
+PLAYING_IMAGE = "playing"
+PAUSED_IMAGE = "paused"
+
+
+def resource_path(relative_path):
+ """Get absolute path to resource, works for dev and PyInstaller"""
+ base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
+ return os.path.join(base_path, relative_path)
+
+
+icon_path = resource_path(os.path.join("assets", "icon.ico"))
+
+
+class VLCDiscordRP:
+ def __init__(self):
+ self.rpc = None
+ self.status_file = self._find_status_file()
+ self.last_status = {}
+ self.connected = False
+ self.last_file_mod_time = 0
+ self.last_check_time = 0
+ self.simulated_position = 0
+ self.last_position_update = 0
+
+ def _find_status_file(self):
+ """Find the status file created by the VLC Lua script"""
+
+ possible_paths = [
+ os.path.join(
+ os.environ.get("APPDATA", ""), "vlc", "vlc_discord_status.json"
+ ),
+ os.path.expanduser(
+ "~/Library/Application Support/org.videolan.vlc/vlc_discord_status.json"
+ ),
+ os.path.expanduser("~/.local/share/vlc/vlc_discord_status.json"),
+ os.path.expanduser("~/.config/vlc/vlc_discord_status.json"),
+ "/tmp/vlc_discord_status.json",
+ ]
+
+ for path in possible_paths:
+ if os.path.exists(path):
+ logger.info(f"Found status file at: {path}")
+ return path
+
+ logger.warning("Status file not found. Using default location.")
+ return possible_paths[0]
+
+ def connect(self):
+ """Connect to Discord"""
+ try:
+ self.rpc = pypresence.Presence(CLIENT_ID)
+ self.rpc.connect()
+ self.connected = True
+ logger.info("Connected to Discord")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to connect to Discord: {e}")
+ self.connected = False
+ return False
+
+ def read_status(self):
+ """Read the status file"""
+ try:
+ if not os.path.exists(self.status_file):
+ return None
+
+ mod_time = os.path.getmtime(self.status_file)
+ if mod_time <= self.last_file_mod_time:
+ return None
+
+ self.last_file_mod_time = mod_time
+
+ with open(self.status_file, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except json.JSONDecodeError:
+ logger.error("Invalid JSON in status file")
+ return None
+ except Exception as e:
+ logger.error(f"Error reading status file: {e}")
+ return None
+
+ def update_presence(self):
+ """Update Discord Rich Presence based on VLC status"""
+ status = self.read_status()
+ current_time = int(time.time())
+
+ if not status and self.last_status and self.last_status.get("active", False):
+ elapsed = current_time - self.last_check_time
+ self.last_check_time = current_time
+
+ if self.last_status.get("status") == "playing":
+
+ playback = self.last_status.get("playback", {})
+ if playback.get("position") is not None:
+ self.simulated_position = playback.get("position", 0) + elapsed
+
+ if self.simulated_position > playback.get("duration", 0):
+ self.simulated_position = playback.get("duration", 0)
+
+ playback["position"] = self.simulated_position
+ playback["remaining"] = max(
+ 0, playback.get("duration", 0) - self.simulated_position
+ )
+ self.last_status["playback"] = playback
+
+ status = self.last_status
+
+ if not status:
+ return False
+
+ self.last_check_time = current_time
+
+ if status != self.last_status:
+ self.last_status = status
+
+ playback = status.get("playback", {})
+ if playback.get("position") is not None:
+ self.simulated_position = playback.get("position", 0)
+ self.last_position_update = current_time
+
+ if not status.get("active", False) or status.get("status") == "idle":
+ if self.connected:
+ try:
+ self.rpc.clear()
+ logger.info("Cleared presence (VLC inactive or idle)")
+ except Exception as e:
+ logger.error(f"Error clearing presence: {e}")
+ self.connected = False
+ return True
+
+ media = status.get("media", {})
+ playback = status.get("playback", {})
+ current_status = status.get("status", "idle")
+
+ details = media.get("title", "Unknown")
+ if len(details) > 128:
+ details = details[:125] + "..."
+
+ is_likely_audio = bool(media.get("artist") or media.get("album"))
+
+ activity_type = (
+ ActivityType.LISTENING if is_likely_audio else ActivityType.WATCHING
+ )
+
+ if media.get("artist"):
+ state = f"by {media['artist']}"
+ if media.get("album"):
+ state += f" • {media['album']}"
+ elif media.get("album"):
+ state = f"from {media['album']}"
+
+ else:
+
+ if activity_type == ActivityType.WATCHING:
+ state = "Now playing" if current_status == "playing" else "Paused"
+ else:
+ state = "Now playing" if current_status == "playing" else "Paused"
+ if len(state) > 128:
+ state = state[:125] + "..."
+
+ start_timestamp = None
+ end_timestamp = None
+
+ if playback.get("position") is not None and playback.get("duration", 0) > 0:
+ position = playback.get("position", 0)
+ duration = playback.get("duration", 0)
+
+ if current_status == "playing":
+
+ start_timestamp = current_time - position
+
+ if 0 < duration < 86400: # Max 24 hours
+ end_timestamp = start_timestamp + duration
+ else:
+
+ pass
+
+ try:
+ if not self.connected and not self.connect():
+ return False
+
+ small_image = PAUSED_IMAGE if current_status == "paused" else None
+ small_text = "Paused" if current_status == "paused" else "Playing"
+
+ self.rpc.update(
+ details=details,
+ state=state,
+ large_image=LARGE_IMAGE,
+ large_text="VLC Media Player",
+ small_image=small_image,
+ small_text=small_text,
+ start=start_timestamp if current_status == "playing" else None,
+ end=end_timestamp if current_status == "playing" else None,
+ activity_type=activity_type,
+ )
+
+ activity_name = (
+ "Listening to"
+ if activity_type == ActivityType.LISTENING
+ else "Watching"
+ )
+ logger.info(f"Updated presence: {activity_name} {details} - {state}")
+ return True
+ except Exception as e:
+ logger.error(f"Error updating presence: {e}")
+ self.connected = False
+ return False
+
+ def run(self):
+ """Main loop"""
+ logger.info("Starting VLC Discord Rich Presence Handler")
+ self.last_check_time = int(time.time())
+
+ while True:
+ try:
+ self.update_presence()
+ time.sleep(1)
+ except KeyboardInterrupt:
+ logger.info("Exiting due to user interrupt")
+ break
+ except Exception as e:
+ logger.error(f"Unexpected error: {e}")
+ time.sleep(5)
+
+ if self.connected:
+ try:
+ self.rpc.clear()
+ self.rpc.close()
+ except Exception as e:
+ logger.error(f"Error during cleanup: {e}")
+
+ logger.info("Handler stopped")
+
+
+if __name__ == "__main__":
+ handler = VLCDiscordRP()
+ handler.run()
diff --git a/version_info.txt b/version_info.txt
new file mode 100644
index 0000000..99b9172
--- /dev/null
+++ b/version_info.txt
@@ -0,0 +1,43 @@
+# UTF-8
+#
+# For more details about fixed file info 'ffi' see:
+# http://msdn.microsoft.com/en-us/library/ms646997.aspx
+VSVersionInfo(
+ ffi=FixedFileInfo(
+ # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
+ # Set not needed items to zero 0.
+ filevers=(1, 0, 0, 0),
+ prodvers=(1, 0, 0, 0),
+ # Contains a bitmask that specifies the valid bits 'flags'r
+ mask=0x3f,
+ # Contains a bitmask that specifies the Boolean attributes of the file.
+ flags=0x0,
+ # The operating system for which this file was designed.
+ # 0x4 - NT and there is no need to change it.
+ OS=0x40004,
+ # The general type of file.
+ # 0x1 - the file is an application.
+ fileType=0x1,
+ # The function of the file.
+ # 0x0 - the function is not defined for this fileType
+ subtype=0x0,
+ # Creation date and time stamp.
+ date=(0, 0)
+ ),
+ kids=[
+ StringFileInfo(
+ [
+ StringTable(
+ u'040904B0',
+ [StringStruct(u'CompanyName', u''),
+ StringStruct(u'FileDescription', u'VLC Discord Rich Presence'),
+ StringStruct(u'FileVersion', u'1.0.0'),
+ StringStruct(u'InternalName', u'VLC Discord Presence'),
+ StringStruct(u'LegalCopyright', u''),
+ StringStruct(u'OriginalFilename', u'VLC Discord Presence.exe'),
+ StringStruct(u'ProductName', u'VLC Discord Rich Presence'),
+ StringStruct(u'ProductVersion', u'1.0.0')])
+ ]),
+ VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
+ ]
+)
\ No newline at end of file
diff --git a/vlc_discord_rp.spec b/vlc_discord_rp.spec
new file mode 100644
index 0000000..33d778d
--- /dev/null
+++ b/vlc_discord_rp.spec
@@ -0,0 +1,41 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+a = Analysis(
+ ['src\\discord_handler.py'],
+ pathex=[],
+ binaries=[],
+ datas=[
+ ('assets', 'assets'), # Include all assets
+ ('lua', 'lua'), # Include the Lua script for installation
+ ],
+ hiddenimports=['pypresence'],
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.datas,
+ [],
+ name='VLC Discord Presence', # More professional name
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ runtime_tmpdir=None,
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon='assets/icon.ico',
+ version='version_info.txt', # Add version info
+)
\ No newline at end of file