diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..9b3796a8 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,34 @@ +name: Playwright Tests +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@master + with: + node-version: 18 + - name: Install dependencies + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Build + run: npm run build + - name: Run Playwright tests + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test:playwright + # if: matrix.os == 'ubuntu-latest' + # - run: npm run test + # if: matrix.os != 'ubuntu-latest' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-output + path: test-output/ + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33ffac3b..c14a98c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,22 +6,18 @@ on: workflow_dispatch: jobs: - publish: - name: "Publish" - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [windows-latest, ubuntu-latest] + publish_windows: + name: "Publish Windows" + runs-on: windows-latest steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install Node.js and NPM uses: actions/setup-node@master with: - node-version: 18 + node-version: 20 - name: Install dependencies # npm ci is better, but requires package-lock.json file @@ -34,18 +30,62 @@ jobs: CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} run: npm run release + publish_linux: + name: "Publish Linux x64" + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + + - name: Install Node.js and NPM + uses: actions/setup-node@master + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build and release app + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run release + + # This needs it's own build + # https://github.com/ChurchApps/FreeShow/issues/562 + publish_linux_arm: + name: "Publish Linux arm64" + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + + - name: Install Node.js and NPM + uses: actions/setup-node@master + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build and release app + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run release:arm64 + publish_mac: name: "Publish MacOS" runs-on: macos-latest steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install Node.js and NPM uses: actions/setup-node@master with: - node-version: 18 + node-version: 20 # Change Python version: https://github.com/nodejs/node-gyp/issues/2869 - name: Install Python 3.11 diff --git a/.gitignore b/.gitignore index 74c546f3..54e47763 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ /node_modules/ /public/build/ +/public/*.js.map +/public/*.ts /build/ /dist/ *.env .DS_Store -package-lock.json \ No newline at end of file +package-lock.json + +test-output/ +test-results/ diff --git a/README.md b/README.md index 2d488967..e7774965 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ - + -[![Downloads](https://img.shields.io/github/downloads/vassbo/freeshow/total)](https://github.com/vassbo/freeshow/releases) -[![Licence](https://img.shields.io/badge/licence-GPL-blue.svg)](https://github.com/vassbo/freeshow/blob/main/LICENSE) +[![Downloads](https://img.shields.io/github/downloads/ChurchApps/freeshow/total)](https://github.com/ChurchApps/freeshow/releases) +[![Licence](https://img.shields.io/badge/licence-GPL-blue.svg)](https://github.com/ChurchApps/freeshow/blob/main/LICENSE) +[![Playwright Tests](https://github.com/ChurchApps/FreeShow/actions/workflows/playwright.yml/badge.svg?branch=dev)](https://github.com/ChurchApps/FreeShow/actions/workflows/playwright.yml) # FreeShow @@ -29,10 +30,12 @@ FreeShow is a free and open-source presentation program that makes it easy to sh FreeShow exists because the creator found that other simular programs was either expensive or complex to use. He wanted to create a program that was easy to use and affordable for everyone, from small churches to large venues. FreeShow is now used by people all over the world. ## Support Us + The only reason this program is free is because of the generous support from users. If you want to support us to keep this free, please head over to [ChurchApps](https://churchapps/partner) or [sponsor us on GitHub](https://github.com/sponsors/ChurchApps/). Thank you so much! ## Join the Community -We have a great community for end-users on [Facebook](https://www.facebook.com/groups/freeshowapp). It's a good way to ask questions, get tips and follow new updates. Come join us! + +We have a great community for end-users on [Facebook](https://www.facebook.com/groups/freeshowapp). It's a good way to ask questions, get tips and follow new updates. Come join us! ## Help the development @@ -45,10 +48,11 @@ You are welcome to contribute to the code! ## Report an issue or request a feature -Create an [issue on GitHub](https://github.com/vassbo/freeshow/issues). +Create an [issue on GitHub](https://github.com/ChurchApps/freeshow/issues). ## Join us on Slack -If you would like to get involved contributing in any way, head over to our [Slack Channel](https://join.slack.com/t/livechurchsolutions/shared_invite/zt-i88etpo5-ZZhYsQwQLVclW12DKtVflg) and introduce yourself. We'd love to hear from you. + +If you would like to get involved contributing in any way, head over to our [Slack Channel](https://join.slack.com/t/livechurchsolutions/shared_invite/zt-i88etpo5-ZZhYsQwQLVclW12DKtVflg) and introduce yourself. We'd love to hear from you. ## Give feedback diff --git a/package.json b/package.json index a1112612..eb9d23ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freeshow", - "version": "1.1.7", + "version": "1.1.8", "private": true, "main": "build/electron/index.js", "description": "Show song lyrics and more for free!", @@ -20,16 +20,20 @@ "build:electron:prod": "cross-env NODE_ENV=production tsc --p ./tsconfig.electron.prod.json", "validate:svelte": "svelte-check", "start:electron:run": "electron .", - "start:electron:dev": "npm-run-all -s build:electron:dev start:electron:run", + "start:electron:dev": "npm-run-all -s build:electron:dev start:electron:script start:electron:run", + "start:electron:script": "node scripts/electronDevPostBuild.js", "start:electron": "npm-run-all -p build:electron:dev:watch start:electron:dev", "test": "npm-run-all -s build test:playwright", - "test:playwright": "cross-env NODE_ENV=production npx playwright test", + "test:playwright": "npx playwright test", "postinstall": "electron-builder install-app-deps", "prepack": "npm run build", "pack": "electron-builder --dir", "prerelease": "npm run build", "release": "electron-builder", "postrelease": "node scripts/cleanBuilds.js", + "prerelease:arm64": "npm run build", + "release:arm64": "electron-builder --arm64", + "postrelease:arm64": "node scripts/cleanBuilds.js", "lint:electron": "eslint -c eslint.electron.json --ext .js,.ts src/electron", "lint:svelte": "eslint -c eslint.svelte.json --ext .js,.ts src/frontend", "lint": "npm-run-all -s lint:electron lint:svelte", @@ -105,16 +109,8 @@ ], "category": "AudioVideo", "target": [ - { - "target": "AppImage", - "arch": [ - "x64", - "arm64" - ] - }, - { - "target": "deb" - } + "AppImage", + "deb" ], "icon": "build/public" }, @@ -136,6 +132,7 @@ "@types/express": "^4.17.13", "@types/follow-redirects": "^1.14.2", "@types/sqlite3": "^3.1.8", + "@types/tmp": "^0.2.6", "@types/vimeo__player": "^2.16.2", "electron": "^21.2.2", "electron-builder": "24.12.0", @@ -148,8 +145,8 @@ "rollup-plugin-svelte": "^7.1.6", "svelte": "^3.46.0", "svelte-check": "^2.2.12", + "svelte-inspector": "vassbo/svelte-inspector#78307db", "svelte-preprocess": "^4.10.1", - "svelte-virtual": "^0.6.2", "tslib": "^2.0.0", "typescript": "^4.5.4" }, @@ -158,6 +155,7 @@ "@mapbox/node-pre-gyp": "^1.0.11", "@sveltejs/svelte-virtual-list": "^3.0.1", "@vimeo/player": "^2.16.4", + "axios": "^1.7.2", "chord-transposer": "^3.0.9", "cross-env": "^7.0.3", "electron-store": "^8.0.1", @@ -181,6 +179,7 @@ "sqlite-to-json": "^0.1.3", "sqlite3": "5.1.6", "svelte-youtube": "0.0.2", + "tmp": "^0.2.3", "uid": "^2.0.0", "word-extractor": "^1.0.4", "youtube-iframe": "^1.0.3" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..dbe73119 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + // 'github' for GitHub Actions CI to generate annotations, plus a concise 'dot' + // default 'line' when running locally + reporter: [[process.env.CI ? "html" : "line", { outputFolder: "test-output/playwright-report" }]], +}) diff --git a/public/assets/beat-hi.mp3 b/public/assets/beat-hi.mp3 new file mode 100644 index 00000000..d9a1a37b Binary files /dev/null and b/public/assets/beat-hi.mp3 differ diff --git a/public/assets/beat-lo.mp3 b/public/assets/beat-lo.mp3 new file mode 100644 index 00000000..882367bb Binary files /dev/null and b/public/assets/beat-lo.mp3 differ diff --git a/public/global.css b/public/global.css index 45523575..17a2a671 100644 --- a/public/global.css +++ b/public/global.css @@ -40,6 +40,8 @@ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; --font-size: 1em; + --border-radius: 0; + /* --navigation-width: 18vw; */ --navigation-width: 300px; } diff --git a/public/import-logos/calendar.webp b/public/import-logos/calendar.webp deleted file mode 100644 index e075d360..00000000 Binary files a/public/import-logos/calendar.webp and /dev/null differ diff --git a/public/import-logos/chordpro.webp b/public/import-logos/chordpro.webp index a65b6f35..640aecea 100644 Binary files a/public/import-logos/chordpro.webp and b/public/import-logos/chordpro.webp differ diff --git a/public/import-logos/clipboard.webp b/public/import-logos/clipboard.webp index b4fd4fa4..e6c1f46d 100644 Binary files a/public/import-logos/clipboard.webp and b/public/import-logos/clipboard.webp differ diff --git a/public/import-logos/easyworship.webp b/public/import-logos/easyworship.webp index b2998ceb..7f1708a1 100644 Binary files a/public/import-logos/easyworship.webp and b/public/import-logos/easyworship.webp differ diff --git a/public/import-logos/freeshow.webp b/public/import-logos/freeshow.webp index 7d7ac6c4..f191cd91 100644 Binary files a/public/import-logos/freeshow.webp and b/public/import-logos/freeshow.webp differ diff --git a/public/import-logos/openlp.webp b/public/import-logos/openlp.webp index 7651df0e..6976d854 100644 Binary files a/public/import-logos/openlp.webp and b/public/import-logos/openlp.webp differ diff --git a/public/import-logos/opensong.webp b/public/import-logos/opensong.webp index bd43c3ab..fe5dc12b 100644 Binary files a/public/import-logos/opensong.webp and b/public/import-logos/opensong.webp differ diff --git a/public/import-logos/pdf.webp b/public/import-logos/pdf.webp index 43957aa7..fc1ff25a 100644 Binary files a/public/import-logos/pdf.webp and b/public/import-logos/pdf.webp differ diff --git a/public/import-logos/powerpoint.webp b/public/import-logos/powerpoint.webp index 6a2f6135..732c32a6 100644 Binary files a/public/import-logos/powerpoint.webp and b/public/import-logos/powerpoint.webp differ diff --git a/public/import-logos/propresenter.webp b/public/import-logos/propresenter.webp index 17a08899..97f829b9 100644 Binary files a/public/import-logos/propresenter.webp and b/public/import-logos/propresenter.webp differ diff --git a/public/import-logos/scripture.webp b/public/import-logos/scripture.webp deleted file mode 100644 index 7c795887..00000000 Binary files a/public/import-logos/scripture.webp and /dev/null differ diff --git a/public/import-logos/softprojector.webp b/public/import-logos/softprojector.webp new file mode 100644 index 00000000..c80b8135 Binary files /dev/null and b/public/import-logos/softprojector.webp differ diff --git a/public/import-logos/txt.webp b/public/import-logos/txt.webp index 524e7231..02589255 100644 Binary files a/public/import-logos/txt.webp and b/public/import-logos/txt.webp differ diff --git a/public/import-logos/videopsalm.webp b/public/import-logos/videopsalm.webp index e44a39e9..c8ca6794 100644 Binary files a/public/import-logos/videopsalm.webp and b/public/import-logos/videopsalm.webp differ diff --git a/public/import-logos/word.webp b/public/import-logos/word.webp index 3d4d0172..9be05a4f 100644 Binary files a/public/import-logos/word.webp and b/public/import-logos/word.webp differ diff --git a/public/import-logos/zefania.webp b/public/import-logos/zefania.webp index 858831fc..d566dfe6 100644 Binary files a/public/import-logos/zefania.webp and b/public/import-logos/zefania.webp differ diff --git a/public/index.html b/public/index.html index 740e775f..26824824 100644 --- a/public/index.html +++ b/public/index.html @@ -3,16 +3,18 @@ - - - - - - + + + + + + + + FreeShow - + diff --git a/public/lang/de.json b/public/lang/de.json index cde61fd8..e61ac5e5 100644 --- a/public/lang/de.json +++ b/public/lang/de.json @@ -372,7 +372,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", diff --git a/public/lang/en.json b/public/lang/en.json index e77ae425..6f94fe79 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -117,7 +117,8 @@ "slide": "Clear slide", "overlays": "Clear overlays", "audio": "Clear audio", - "nextTimer": "Clear next slide timer" + "nextTimer": "Clear next slide timer", + "drawing": "Clear drawing" }, "remove": { "background": "Remove background", @@ -159,6 +160,15 @@ "online": "Online", "recommended": "Recommended" }, + "audio": { + "settings": "Audio settings", + "mute_when_video_plays": "Mute when video plays", + "metronome": "Metronome", + "toggle_metronome": "Toggle metronome", + "tempo": "Tempo", + "bpm": "BPM", + "beats": "Beats" + }, "menu": { "show": "Show", "_title_show": "Presenting", @@ -314,6 +324,8 @@ "current": "Current", "global": "Global", "toggle_global_group": "Toggle global groups", + "group_shortcut": "Group shortcut", + "group_template": "Group template", "intro": "Intro", "verse": "Verse", "pre_chorus": "Pre-Chorus", @@ -379,7 +391,7 @@ "verse_undefined": "Verse {} does not exist in this chapter.", "recording_started": "Recording started!", "recording_stopped": "Recording stopped!", - "starting_show": "Starting show", + "starting_action": "Starting action", "less_than_minute": "in less than a minute.", "less_than_seconds": "in less than {} seconds.", "now": "now!", @@ -387,7 +399,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", @@ -431,7 +443,11 @@ "lyrics": "Lyrics view", "text": "Text edit", "update": "Update show", - "slide_template": "Slide template" + "slide_template": "Slide template", + "search_results": "Search Results", + "source": "Source", + "artist": "Artist", + "song": "Song" }, "actions": { "rename": "Rename", @@ -494,7 +510,7 @@ "add_color": "Add color", "format": "Format", "find_replace": "Find and replace text", - "cut_in_half": "Cut in half", + "cut_in_half": "Split in two", "find": "Find", "replace": "Replace", "case_sensitive": "Case sensitive", @@ -514,6 +530,7 @@ "change_drawer_category": "Change drawer category", "toggle_drawer": "Toggle drawer", "slide_actions": "Slide actions", + "item_actions": "Item actions", "clear_history": "Clear history", "set_key": "Set key", "custom_key": "Set custom value", @@ -531,7 +548,6 @@ "remove_layers": "Remove layers", "start_recording": "Start recording", "stop_recording": "Stop recording", - "activate_on_startup": "Activate on startup", "index_select_project": "Select project by index", "next_project_item": "Next project item", "previous_project_item": "Previous project item", @@ -549,13 +565,25 @@ "change_volume": "Change volume", "start_audio_stream": "Start audio stream", "start_playlist": "Start playlist", + "playlist_next": "Next track in playlist", + "start_metronome": "Start metronome", "start_slide_timers": "Start timers on active slide", "id_select_output_style": "Select output style by ID", "change_output_style": "Change output style", "change_transition": "Change transition", "change_variable": "Change variable", "start_trigger": "Start trigger", - "run_action": "Run action" + "run_action": "Run action", + "toggle_action": "Toggle action", + "custom_activation": "Custom activation", + "activate_on_startup": "Activate on startup", + "activate_save": "Activate on save", + "activate_slide_clicked": "Activate on slide click", + "activate_video_starting": "Activate when video is starting", + "activate_video_ending": "Activate when video is ending", + "activate_timer_ending": "Activate when timer is ending", + "activate_scripture_start": "Activate when scripture is started", + "activate_show_created": "Activate when show is created" }, "animate": { "change": "Change", @@ -621,6 +649,7 @@ "createNew": "Create new", "selectAll": "Select all", "force_outputs": "Force outputs", + "align_with_screen": "Align with screen", "toggle_output": "Toggle output", "move_to_front": "Move to front", "lock_to_output": "Lock to output", @@ -658,6 +687,9 @@ "background_color": "Background color", "background_opacity": "Background opacity", "background_image": "Background image", + "background_media": "Background media", + "overlay_content": "Add overlay content", + "different_first_template": "Custom template on first slide", "media_fit": "Media fit", "size": "Size", "chords": "Chords", @@ -741,6 +773,7 @@ "variable": "Variable", "web": "Website", "visualizer": "Visualizer", + "captions": "Captions", "icon": "Icon" }, "borders": { @@ -793,6 +826,13 @@ "analog": "Analog", "seconds": "Seconds" }, + "captions": { + "info": "Please click the URL to open in your browser if you haven't already, or open it on any other device! Make sure to give access to your microphone, and use Google Chrome for the best performance.", + "language": "Transcription language", + "translate": "Translate to", + "showtime": "Display duration", + "powered_by": "Powered by" + }, "midi": { "midi": "MIDI", "activate": "Activate by MIDI signal", @@ -861,6 +901,8 @@ "connection": "Connection", "cloud": "Cloud", "calendar": "Calendar", + "text_import": "Text", + "media_import": "Media", "other": "Other", "language": "Language", "autosave": "Autosave", @@ -875,6 +917,7 @@ "select_display": "Click on the screen where you want to display the output window.", "manual_input_hint": "Can't find the display? Click here to manually change the position.", "manual_drag_hint": "You can also hold ctrl/cmd over an active output window to manually drag it around.", + "allow_main_screen": "Allow output on main screen", "identify_screens": "Identify screens", "new_output": "New output", "enable_key_output": "Enable alpha key output", @@ -886,7 +929,8 @@ "color_when_active": "Color when active", "fixed": "Fixed", "lines": "Lines", - "override_with_template": "Override style with template", + "override_with_template": "Override slide with template", + "override_scripture_with_template": "Override scripture with template", "active_layers": "Active layers", "window": "Window", "active_style": "Use style", @@ -897,9 +941,11 @@ "full_colors": "Full slide group colors", "auto_output": "Activate output screen on startup", "hide_cursor_in_output": "Hide cursor in output", + "clear_media_when_finished": "Clear media when finished", "disable_presenter_controller_keys": "Disable presenter controller keys", "default_project_name": "Default project name", "audio_fade_duration": "Audio fade duration", + "audio_crossfade": "Audio crossfade", "max_auto_font_size": "Max auto font size", "resolution": "Resolution", "cropping": "Cropping", @@ -917,6 +963,7 @@ "font": "Font", "font_family": "Font family", "font_size": "Font size", + "border_radius": "Border radius", "colors": "Colors", "add_group": "Add group", "group_shortcut": "Shortcut to activate group", @@ -943,6 +990,7 @@ "preview_frame_rate": "Preview frame rate", "auto": "Auto", "optimized": "Optimized", + "reduced": "Reduced", "full": "Full" }, "sort": { @@ -956,7 +1004,7 @@ "calendar": { "type": "Type", "event": "Event", - "show": "Schedule show", + "schedule_action": "Schedule action", "name": "Name", "color": "Color", "time": "Time", @@ -986,6 +1034,7 @@ "custom": "Or import your own", "max_verses": "Max verses per slide", "verse_numbers": "Verse numbers", + "verses_on_individual_lines": "Verses on individual lines", "version": "Show version", "reference": "Show reference", "combine_with_text": "Combine with text", @@ -1015,6 +1064,8 @@ "current_slide": "for current slide", "text": "Text transition", "media": "Media transition", + "slide_transition": "Slide transition", + "background_transition": "Background transition", "duration": "Duration", "easing": "Easing", "type": "Type", diff --git a/public/lang/en_GB.json b/public/lang/en_GB.json index fd405949..431534bd 100644 --- a/public/lang/en_GB.json +++ b/public/lang/en_GB.json @@ -131,6 +131,10 @@ "media": { "_loop": "Loop", "play": "Play", + "play_multiple": "Play multiple", + "toggle_shuffle": "Toggle shuffle", + "next": "Next", + "previous": "Previous", "play_no_filters": "Play without filters", "favourite": "Favourite", "pause": "Pause", @@ -155,6 +159,13 @@ "online": "Online", "recommended": "Recommended" }, + "audio": { + "metronome": "Metronome", + "toggle_metronome": "Toggle metronome", + "tempo": "Tempo", + "bpm": "BPM", + "beats": "Beats" + }, "menu": { "show": "Show", "_title_show": "Presenting", @@ -280,6 +291,8 @@ "effects": "Effects", "scripture": "Scripture", "calendar": "Calendar", + "functions": "Functions", + "actions": "Actions", "player": "Player", "live": "Live", "timers": "Timers", @@ -308,6 +321,8 @@ "current": "Current", "global": "Global", "toggle_global_group": "Toggle global groups", + "group_shortcut": "Group shortcut", + "group_template": "Group template", "intro": "Intro", "verse": "Verse", "pre_chorus": "Pre-Chorus", @@ -334,7 +349,6 @@ "change_name": "Change name on", "choose_screen": "Choose screen", "change_output_values": "Change output values", - "choose_style": "Choose style", "set_time": "Set time", "animate": "Animate", "next_timer": "Next slide timer", @@ -346,7 +360,7 @@ "edit_event": "Edit event", "about": "About", "history": "History", - "midi": "MIDI", + "action": "Action", "connect": "Connect", "cloud_update": "Syncing with cloud", "cloud_method": "Data location", @@ -356,7 +370,7 @@ "manage_colors": "Manage colours", "choose_camera": "Choose camera", "initialize": "Welcome to FreeShow", - "unsaved": "You have not saved yet! Are you sure you want to quit?", + "unsaved": "Are you sure you want to quit?", "cancel": "Cancel", "continue": "Continue", "reset_all": "Reset everything", @@ -382,7 +396,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", @@ -403,6 +417,7 @@ "variable": "New variable", "trigger": "New trigger", "audio_stream": "New audio stream", + "playlist": "New playlist", "category": "New category", "private": "New private show", "folder": "New folder", @@ -411,6 +426,7 @@ "template": "New template", "scripture": "New scripture", "collection": "New collection", + "action": "New action", "event": "New event" }, "show": { @@ -424,7 +440,11 @@ "lyrics": "Lyrics view", "text": "Text edit", "update": "Update show", - "slide_template": "Slide template" + "slide_template": "Slide template", + "search_results": "Search Results", + "source": "Source", + "artist": "Artist", + "song": "Song" }, "actions": { "rename": "Rename", @@ -433,7 +453,6 @@ "remove_group": "Remove group", "choose_group": "Choose group", "goto_group": "Go to group", - "change_output_style": "Change output style", "active_outputs": "Active outputs", "all_outputs": "All outputs", "specific_outputs": "Specific outputs", @@ -475,6 +494,8 @@ "home": "Home", "mute": "Mute", "unmute": "Unmute", + "increase_volume": "Increase volume", + "decrease_volume": "Decrease volume", "toggle_time_marker": "Toggle time markers", "add_time_marker": "Add time marker", "bind_to": "Specific outputs", @@ -505,12 +526,14 @@ "change_drawer_item": "Change drawer item", "change_drawer_category": "Change drawer category", "toggle_drawer": "Toggle drawer", - "actions": "Actions", + "slide_actions": "Slide actions", + "item_actions": "Item actions", "clear_history": "Clear history", "set_key": "Set key", "custom_key": "Set custom value", - "play_on_midi": "Play on MIDI in", - "send_midi": "Send MIDI out", + "play_on_midi": "Activate on MIDI signal", + "play_on_midi_tip": "Activate this specific slide when receiving chosen MIDI signal", + "send_midi": "Send MIDI signal", "delete_shows_not_indexed": "Delete shows in 'Shows' folder that are not indexed", "delete_thumbnail_cache": "Delete thumbnail cache", "open_log_file": "Open log file", @@ -520,11 +543,39 @@ "next_after_media": "Next on media finished", "remove_media": "Remove media", "remove_layers": "Remove layers", + "start_recording": "Start recording", + "stop_recording": "Stop recording", "index_select_project": "Select project by index", - "index_select_project_show": "Select project item by index", + "next_project_item": "Next project item", + "previous_project_item": "Previous project item", + "index_select_project_item": "Select project item by index", + "name_select_show": "Select show by name", + "random_slide": "Play random slide", "index_select_slide": "Select slide by index", - "start_recording": "Start recording", - "stop_recording": "Stop recording" + "name_select_slide": "Select slide by name", + "toggle_output_lock": "Toggle output lock", + "toggle_output_windows": "Toggle output windows", + "id_select_group": "Select group by ID", + "id_change_stage_layout": "Change stage layout by ID", + "index_select_overlay": "Select overlay by index", + "name_select_overlay": "Select overlay by name", + "change_volume": "Change volume", + "start_audio_stream": "Start audio stream", + "start_playlist": "Start playlist", + "start_metronome": "Start metronome", + "start_slide_timers": "Start timers on active slide", + "id_select_output_style": "Select output style by ID", + "change_output_style": "Change output style", + "change_transition": "Change transition", + "change_variable": "Change variable", + "start_trigger": "Start trigger", + "run_action": "Run action", + "custom_activation": "Custom activation", + "activate_on_startup": "Activate on startup", + "activate_save": "Activate on save", + "activate_slide_clicked": "Activate on slide click", + "activate_scripture_start": "Activate when scripture is started", + "activate_show_created": "Activate when show is created" }, "animate": { "change": "Change", @@ -551,7 +602,7 @@ "main_folder": "Set main folder manually", "media_folder": "Cloud media folder", "reconnect": "Reconnect", - "sync": "Sync", + "sync": "Sync now", "choose_method_tip": "There is existing data in the cloud. Please choose to either upload from local or download from cloud. The other location will be overwritten.", "local": "Local", "syncing": "Syncing to cloud", @@ -590,6 +641,7 @@ "createNew": "Create new", "selectAll": "Select all", "force_outputs": "Force outputs", + "align_with_screen": "Align with screen", "toggle_output": "Toggle output", "move_to_front": "Move to front", "lock_to_output": "Lock to output", @@ -627,6 +679,9 @@ "background_color": "Background colour", "background_opacity": "Background opacity", "background_image": "Background image", + "background_media": "Background media", + "overlay_content": "Add overlay content", + "different_first_template": "Custom template on first slide", "media_fit": "Media fit", "size": "Size", "chords": "Chords", @@ -684,6 +739,7 @@ "padding": "Padding", "special": "Special", "scrolling": "Scrolling", + "scrolling_speed": "Scrolling speed", "top_bottom": "Top to bottom", "bottom_top": "Bottom to top", "left_right": "Left to right", @@ -762,6 +818,8 @@ "seconds": "Seconds" }, "midi": { + "midi": "MIDI", + "activate": "Activate by MIDI signal", "name": "Name", "input": "Input", "output": "Output", @@ -813,7 +871,9 @@ "font-size": "Font Size", "zeros": "Zeros", "overrun": "Overrun Colour", - "auto_stretch": "Auto stretch content" + "source_output": "Source output", + "auto_stretch": "Auto stretch content", + "labels": "Show labels" }, "settings": { "general": "General", @@ -821,11 +881,12 @@ "groups": "Groups", "styles": "Styles", "display_settings": "Outputs", - "actions": "Actions", "display": "Display", "connection": "Connection", "cloud": "Cloud", "calendar": "Calendar", + "text_import": "Text", + "media_import": "Media", "other": "Other", "language": "Language", "autosave": "Autosave", @@ -840,6 +901,7 @@ "select_display": "Click on the screen where you want to display the output window.", "manual_input_hint": "Can't find the display? Click here to manually change the position.", "manual_drag_hint": "You can also hold ctrl/cmd over an active output window to manually drag it around.", + "allow_main_screen": "Allow output on main screen", "identify_screens": "Identify screens", "new_output": "New output", "enable_key_output": "Enable alpha key output", @@ -851,7 +913,8 @@ "color_when_active": "Colour when active", "fixed": "Fixed", "lines": "Lines", - "override_with_template": "Override style with template", + "override_with_template": "Override slide with template", + "override_scripture_with_template": "Override scripture with template", "active_layers": "Active layers", "window": "Window", "active_style": "Use style", @@ -862,7 +925,10 @@ "full_colors": "Full slide group colours", "auto_output": "Activate output screen on startup", "hide_cursor_in_output": "Hide cursor in output", + "disable_presenter_controller_keys": "Disable presenter controller keys", "default_project_name": "Default project name", + "audio_fade_duration": "Audio fade duration", + "max_auto_font_size": "Max auto font size", "resolution": "Resolution", "cropping": "Cropping", "frame_rate": "Frame rate", @@ -875,9 +941,11 @@ "show_location": "Show location", "data_location": "Data location", "user_data_location": "Save user settings at 'Data location'", + "popup_before_close": "Always display popup before closing", "font": "Font", "font_family": "Font family", "font_size": "Font size", + "border_radius": "Border radius", "colors": "Colours", "add_group": "Add group", "group_shortcut": "Shortcut to activate group", @@ -904,6 +972,7 @@ "preview_frame_rate": "Preview frame rate", "auto": "Auto", "optimized": "Optimised", + "reduced": "Reduced", "full": "Full" }, "sort": { @@ -947,6 +1016,7 @@ "custom": "Or import your own", "max_verses": "Max verses per slide", "verse_numbers": "Verse numbers", + "verses_on_individual_lines": "Verses on individual lines", "version": "Show version", "reference": "Show reference", "combine_with_text": "Combine with text", @@ -976,6 +1046,8 @@ "current_slide": "for current slide", "text": "Text transition", "media": "Media transition", + "slide_transition": "Slide transition", + "background_transition": "Background transition", "duration": "Duration", "easing": "Easing", "type": "Type", diff --git a/public/lang/en_ZM.json b/public/lang/en_ZM.json index a747b300..7d4c7826 100644 --- a/public/lang/en_ZM.json +++ b/public/lang/en_ZM.json @@ -131,6 +131,10 @@ "media": { "_loop": "Loop", "play": "Play", + "play_multiple": "Play multiple", + "toggle_shuffle": "Toggle shuffle", + "next": "Next", + "previous": "Previous", "play_no_filters": "Play without filters", "favourite": "Favourite", "pause": "Pause", @@ -155,6 +159,13 @@ "online": "Online", "recommended": "Recommended" }, + "audio": { + "metronome": "Metronome", + "toggle_metronome": "Toggle metronome", + "tempo": "Tempo", + "bpm": "BPM", + "beats": "Beats" + }, "menu": { "show": "Show", "_title_show": "Presenting", @@ -280,6 +291,8 @@ "effects": "Effects", "scripture": "Scripture", "calendar": "Calendar", + "functions": "Functions", + "actions": "Actions", "player": "Player", "live": "Live", "timers": "Timers", @@ -308,6 +321,8 @@ "current": "Current", "global": "Global", "toggle_global_group": "Toggle global groups", + "group_shortcut": "Group shortcut", + "group_template": "Group template", "intro": "Intro", "verse": "Verse", "pre_chorus": "Pre-Chorus", @@ -334,7 +349,6 @@ "change_name": "Change name on", "choose_screen": "Choose screen", "change_output_values": "Change output values", - "choose_style": "Choose style", "set_time": "Set time", "animate": "Animate", "next_timer": "Next slide timer", @@ -346,7 +360,7 @@ "edit_event": "Edit event", "about": "About", "history": "History", - "midi": "MIDI", + "action": "Action", "connect": "Connect", "cloud_update": "Syncing with cloud", "cloud_method": "Data location", @@ -356,7 +370,7 @@ "manage_colors": "Manage colours", "choose_camera": "Choose camera", "initialize": "Welcome to FreeShow", - "unsaved": "You have not saved yet! Are you sure you want to quit?", + "unsaved": "Are you sure you want to quit?", "cancel": "Cancel", "continue": "Continue", "reset_all": "Reset everything", @@ -382,7 +396,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", @@ -403,6 +417,7 @@ "variable": "New variable", "trigger": "New trigger", "audio_stream": "New audio stream", + "playlist": "New playlist", "category": "New category", "private": "New private show", "folder": "New folder", @@ -411,6 +426,7 @@ "template": "New template", "scripture": "New scripture", "collection": "New collection", + "action": "New action", "event": "New event" }, "show": { @@ -424,7 +440,11 @@ "lyrics": "Lyrics view", "text": "Text edit", "update": "Update show", - "slide_template": "Slide template" + "slide_template": "Slide template", + "search_results": "Search Results", + "source": "Source", + "artist": "Artist", + "song": "Song" }, "actions": { "rename": "Rename", @@ -433,7 +453,6 @@ "remove_group": "Remove group", "choose_group": "Choose group", "goto_group": "Go to group", - "change_output_style": "Change output style", "active_outputs": "Active outputs", "all_outputs": "All outputs", "specific_outputs": "Specific outputs", @@ -475,6 +494,8 @@ "home": "Home", "mute": "Mute", "unmute": "Unmute", + "increase_volume": "Increase volume", + "decrease_volume": "Decrease volume", "toggle_time_marker": "Toggle time markers", "add_time_marker": "Add time marker", "bind_to": "Specific outputs", @@ -505,12 +526,14 @@ "change_drawer_item": "Change drawer item", "change_drawer_category": "Change drawer category", "toggle_drawer": "Toggle drawer", - "actions": "Actions", + "slide_actions": "Slide actions", + "item_actions": "Item actions", "clear_history": "Clear history", "set_key": "Set key", "custom_key": "Set custom value", - "play_on_midi": "Play on MIDI in", - "send_midi": "Send MIDI out", + "play_on_midi": "Activate on MIDI signal", + "play_on_midi_tip": "Activate this specific slide when receiving chosen MIDI signal", + "send_midi": "Send MIDI signal", "delete_shows_not_indexed": "Delete shows in 'Shows' folder that are not indexed", "delete_thumbnail_cache": "Delete thumbnail cache", "open_log_file": "Open log file", @@ -520,11 +543,39 @@ "next_after_media": "Next on media finished", "remove_media": "Remove media", "remove_layers": "Remove layers", + "start_recording": "Start recording", + "stop_recording": "Stop recording", "index_select_project": "Select project by index", - "index_select_project_show": "Select project item by index", + "next_project_item": "Next project item", + "previous_project_item": "Previous project item", + "index_select_project_item": "Select project item by index", + "name_select_show": "Select show by name", + "random_slide": "Play random slide", "index_select_slide": "Select slide by index", - "start_recording": "Start recording", - "stop_recording": "Stop recording" + "name_select_slide": "Select slide by name", + "toggle_output_lock": "Toggle output lock", + "toggle_output_windows": "Toggle output windows", + "id_select_group": "Select group by ID", + "id_change_stage_layout": "Change stage layout by ID", + "index_select_overlay": "Select overlay by index", + "name_select_overlay": "Select overlay by name", + "change_volume": "Change volume", + "start_audio_stream": "Start audio stream", + "start_playlist": "Start playlist", + "start_metronome": "Start metronome", + "start_slide_timers": "Start timers on active slide", + "id_select_output_style": "Select output style by ID", + "change_output_style": "Change output style", + "change_transition": "Change transition", + "change_variable": "Change variable", + "start_trigger": "Start trigger", + "run_action": "Run action", + "custom_activation": "Custom activation", + "activate_on_startup": "Activate on startup", + "activate_save": "Activate on save", + "activate_slide_clicked": "Activate on slide click", + "activate_scripture_start": "Activate when scripture is started", + "activate_show_created": "Activate when show is created" }, "animate": { "change": "Change", @@ -551,7 +602,7 @@ "main_folder": "Set main folder manually", "media_folder": "Cloud media folder", "reconnect": "Reconnect", - "sync": "Sync", + "sync": "Sync now", "choose_method_tip": "There is existing data in the cloud. Please choose to either upload from local or download from cloud. The other location will be overwritten.", "local": "Local", "syncing": "Syncing to cloud", @@ -590,6 +641,7 @@ "createNew": "Create new", "selectAll": "Select all", "force_outputs": "Force outputs", + "align_with_screen": "Align with screen", "toggle_output": "Toggle output", "move_to_front": "Move to front", "lock_to_output": "Lock to output", @@ -627,6 +679,9 @@ "background_color": "Background colour", "background_opacity": "Background opacity", "background_image": "Background image", + "background_media": "Background media", + "overlay_content": "Add overlay content", + "different_first_template": "Custom template on first slide", "media_fit": "Media fit", "size": "Size", "chords": "Chords", @@ -684,6 +739,7 @@ "padding": "Padding", "special": "Special", "scrolling": "Scrolling", + "scrolling_speed": "Scrolling speed", "top_bottom": "Top to bottom", "bottom_top": "Bottom to top", "left_right": "Left to right", @@ -762,6 +818,8 @@ "seconds": "Seconds" }, "midi": { + "midi": "MIDI", + "activate": "Activate by MIDI signal", "name": "Name", "input": "Input", "output": "Output", @@ -813,7 +871,9 @@ "font-size": "Font Size", "zeros": "Zeros", "overrun": "Overrun Colour", - "auto_stretch": "Auto stretch content" + "source_output": "Source output", + "auto_stretch": "Auto stretch content", + "labels": "Show labels" }, "settings": { "general": "General", @@ -821,11 +881,12 @@ "groups": "Groups", "styles": "Styles", "display_settings": "Outputs", - "actions": "Actions", "display": "Display", "connection": "Connection", "cloud": "Cloud", "calendar": "Calendar", + "text_import": "Text", + "media_import": "Media", "other": "Other", "language": "Language", "autosave": "Autosave", @@ -840,6 +901,7 @@ "select_display": "Click on the screen where you want to display the output window.", "manual_input_hint": "Can't find the display? Click here to manually change the position.", "manual_drag_hint": "You can also hold ctrl/cmd over an active output window to manually drag it around.", + "allow_main_screen": "Allow output on main screen", "identify_screens": "Identify screens", "new_output": "New output", "enable_key_output": "Enable alpha key output", @@ -851,7 +913,8 @@ "color_when_active": "Colour when active", "fixed": "Fixed", "lines": "Lines", - "override_with_template": "Override style with template", + "override_with_template": "Override slide with template", + "override_scripture_with_template": "Override scripture with template", "active_layers": "Active layers", "window": "Window", "active_style": "Use style", @@ -862,7 +925,10 @@ "full_colors": "Full slide group colours", "auto_output": "Activate output screen on startup", "hide_cursor_in_output": "Hide cursor in output", + "disable_presenter_controller_keys": "Disable presenter controller keys", "default_project_name": "Default project name", + "audio_fade_duration": "Audio fade duration", + "max_auto_font_size": "Max auto font size", "resolution": "Resolution", "cropping": "Cropping", "frame_rate": "Frame rate", @@ -875,9 +941,11 @@ "show_location": "Show location", "data_location": "Data location", "user_data_location": "Save user settings at 'Data location'", + "popup_before_close": "Always display popup before closing", "font": "Font", "font_family": "Font family", "font_size": "Font size", + "border_radius": "Border radius", "colors": "Colours", "add_group": "Add group", "group_shortcut": "Shortcut to activate group", @@ -904,6 +972,7 @@ "preview_frame_rate": "Preview frame rate", "auto": "Auto", "optimized": "Optimised", + "reduced": "Reduced", "full": "Full" }, "sort": { @@ -947,6 +1016,7 @@ "custom": "Or import your own", "max_verses": "Max verses per slide", "verse_numbers": "Verse numbers", + "verses_on_individual_lines": "Verses on individual lines", "version": "Show version", "reference": "Show reference", "combine_with_text": "Combine with text", @@ -976,6 +1046,8 @@ "current_slide": "for current slide", "text": "Text transition", "media": "Media transition", + "slide_transition": "Slide transition", + "background_transition": "Background transition", "duration": "Duration", "easing": "Easing", "type": "Type", diff --git a/public/lang/es.json b/public/lang/es.json index 54b01f4c..25534a1c 100644 --- a/public/lang/es.json +++ b/public/lang/es.json @@ -372,7 +372,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", diff --git a/public/lang/hu.json b/public/lang/hu.json index 8f3fc436..5261688a 100644 --- a/public/lang/hu.json +++ b/public/lang/hu.json @@ -131,6 +131,10 @@ "media": { "_loop": "Ismétlés", "play": "Lejátszás", + "play_multiple": "Többszörös lejátszás", + "toggle_shuffle": "Keverés átváltása", + "next": "Következő", + "previous": "Előző", "play_no_filters": "Lejátszás szűrők nélkül", "favourite": "Kedvenc", "pause": "Szüneteltetés", @@ -155,6 +159,13 @@ "online": "Online", "recommended": "Ajánlott" }, + "audio": { + "metronome": "Metronóm", + "toggle_metronome": "Metronóm átváltása", + "tempo": "Tempó", + "bpm": "BPM", + "beats": "Ütemek" + }, "menu": { "show": "Műsor", "_title_show": "Bemutatás", @@ -280,6 +291,8 @@ "effects": "Effektusok", "scripture": "Szentírás", "calendar": "Naptár", + "functions": "Funkciók", + "actions": "Műveletek", "player": "Lejátszó", "live": "Élő", "timers": "Időzítők", @@ -308,6 +321,8 @@ "current": "Jelenlegi", "global": "Globális", "toggle_global_group": "Globális csoportok átváltása", + "group_shortcut": "Csoport gyorsbillentyű", + "group_template": "Csoportsablon", "intro": "Előjáték", "verse": "Versszak", "pre_chorus": "Elő-refrén", @@ -334,7 +349,6 @@ "change_name": "Név módosítása", "choose_screen": "Képernyő kiválasztása", "change_output_values": "Kimeneti értékek módosítása", - "choose_style": "Stílus kiválasztása", "set_time": "Idő beállítása", "animate": "Animálás", "next_timer": "Következő dia időzítője", @@ -346,7 +360,7 @@ "edit_event": "Esemény szerkesztése", "about": "Névjegy", "history": "Előzmények", - "midi": "MIDI", + "action": "Művelet", "connect": "Csatlakozás", "cloud_update": "Szinkronizálás a felhővel", "cloud_method": "Adatok helye", @@ -356,7 +370,7 @@ "manage_colors": "Színkezelés", "choose_camera": "Kamera kiválasztása", "initialize": "Üdvözöljük a FreeShow-ban", - "unsaved": "Még nem elmentve! Biztosan kilép?", + "unsaved": "Biztosan ki szeretne léptni?", "cancel": "Mége", "continue": "Folytatás", "reset_all": "Minden visszaállítása", @@ -382,7 +396,7 @@ "no_name": "Nincs név", "media_replaced": "Hiányzó médiafájl helyettesítve a megfelelővel.", "lyrics_undefined": "Nem található dalszöveg!", - "lyrics_copied": "Dalszöveg másolva a Genius-ról!", + "lyrics_copied": "Dalszöveg másolva erről:", "no_pdf_linux": "Nem lehet PDF-ként exportálni Linuxon.", "one_output": "Legalább egy aktív kimenetnek lennie kell!", "empty_cache": "A gyorsítótár üres.", @@ -403,6 +417,7 @@ "variable": "Új változó", "trigger": "Új kiváltó", "audio_stream": "Új hangfolyam", + "playlist": "Új lejátszási lista", "category": "Új kategória", "private": "Új privát műsor", "folder": "Új mappa", @@ -411,6 +426,7 @@ "template": "Új sablon", "scripture": "Új szentírás", "collection": "Új gyűjtemény", + "action": "Új művelet", "event": "Új esemény" }, "show": { @@ -424,7 +440,11 @@ "lyrics": "Dalszöveg nézet", "text": "Szövegszerkesztés", "update": "Műsor frissítése", - "slide_template": "Diasablon" + "slide_template": "Diasablon", + "search_results": "Keresési eredmény", + "source": "Forrás", + "artist": "Művész", + "song": "Dal" }, "actions": { "rename": "Átnevezés", @@ -433,7 +453,6 @@ "remove_group": "Csoport eltávolítása", "choose_group": "Csoport kiválasztása", "goto_group": "Ugrás a csoporthoz", - "change_output_style": "Kimeneti stílus módosítása", "active_outputs": "Aktív kimenetek", "all_outputs": "Minden kimenet", "specific_outputs": "Specifikus kimenetek", @@ -475,6 +494,8 @@ "home": "Kezdőlap", "mute": "Némítás", "unmute": "Visszahangosítás", + "increase_volume": "Hangerő növelése", + "decrease_volume": "Hangerő csökkentése", "toggle_time_marker": "Időjelölők átváltása", "add_time_marker": "Időjelölő hozzáadása", "bind_to": "Speciális kimenetek", @@ -505,12 +526,14 @@ "change_drawer_item": "Rajzoló elem módosítása", "change_drawer_category": "Rajzoló kategória módosítása", "toggle_drawer": "Rajzoló átváltása", - "actions": "Műveletek", + "slide_actions": "Diaműveletek", + "item_actions": "Elemművelet", "clear_history": "Előzmények törlése", "set_key": "Billentyű beállítása", "custom_key": "Egyéni érték beállítása", - "play_on_midi": "Lejátszás MIDI bemeneten", - "send_midi": "MIDI kimenet küldése", + "play_on_midi": "Aktiválás MIDI jelre", + "play_on_midi_tip": "A kiválasztott MIDI jel fogadásakor aktiválja ezt a speciális diát.", + "send_midi": "MIDI jel küldése", "delete_shows_not_indexed": "A „Shows” mappában lévő nem indexelt műsorok törlése", "delete_thumbnail_cache": "Bélyegkép gyorsítótár törlése", "open_log_file": "Naplófájl megnyitása", @@ -520,11 +543,39 @@ "next_after_media": "Kész a következő média", "remove_media": "Média eltávolítása", "remove_layers": "Rétegek eltávolítása", + "start_recording": "Felvétel indítása", + "stop_recording": "Felvétel leállítása", "index_select_project": "Projekt kijelölése index alapján", - "index_select_project_show": "Projektelem kijelölése index alapján", + "next_project_item": "Következő projektelem", + "previous_project_item": "Előző projektelem", + "index_select_project_item": "Projektelem kijelölése index alapján", + "name_select_show": "Műsor kijelölése név alapján", + "random_slide": "Véletlenszerű dia vetítése", "index_select_slide": "Dia kijelölése index alapján", - "start_recording": "Felvétel indítása", - "stop_recording": "Felvétel leállítása" + "name_select_slide": "Dia kijelölése név alapján", + "toggle_output_lock": "Kimenet zárolásának átváltása", + "toggle_output_windows": "Kimeneti abalakok átváltása", + "id_select_group": "Csoport kijelölése azonosító alapján", + "id_change_stage_layout": "Színpadelrendezés módosítása azonosító alapján", + "index_select_overlay": "Áttűnés kijelölése index alapján", + "name_select_overlay": "Áttűnés kijelölése név alapján", + "change_volume": "Hangerő módosítása", + "start_audio_stream": "Hangfolyam indítása", + "start_playlist": "Lejátszási lista indítása", + "start_metronome": "Metronóm indítása", + "start_slide_timers": "Időzítők indítása az aktív dián", + "id_select_output_style": "Kimeneti stílus kijelölése azonosító alapján", + "change_output_style": "Kimeneti stílus módosítása", + "change_transition": "Átemenet módosítása", + "change_variable": "Változó módosítása", + "start_trigger": "Kiváltó indítása", + "run_action": "Művelet futtatása", + "custom_activation": "Egyedi aktiválása", + "activate_on_startup": "Aktiválás indításkor", + "activate_save": "Aktiválás mentéskor", + "activate_slide_clicked": "Aktiválás dián való kattintáskor", + "activate_scripture_start": "Aktiválás szentírás indításakor", + "activate_show_created": "Aktiválás műsor létrehozásakor" }, "animate": { "change": "Módosítás", @@ -551,7 +602,7 @@ "main_folder": "Fő mappa kézi beállítása", "media_folder": "Felhő médiamappa", "reconnect": "Újracsatlakozás", - "sync": "Szinkronizálás", + "sync": "Szinkronizálás most", "choose_method_tip": "Vannak meglévő adatok a felhőben. Kérjük, válassza a helyi adatok feltöltését vagy a felhőből való letöltést. A másik hely felülírásra kerül.", "local": "Helyi", "syncing": "Szinkronizálás a felhőbe", @@ -590,6 +641,7 @@ "createNew": "Új létrehozása", "selectAll": "Összes kiválasztása", "force_outputs": "Kimenetek kényszerítése", + "align_with_screen": "Igazítás a képernyővel", "toggle_output": "Kimenet átváltása", "move_to_front": "Előre mozgatás", "lock_to_output": "Rögzítés a kimenethez", @@ -627,6 +679,9 @@ "background_color": "Háttérszín", "background_opacity": "Háttérátlátszatlanság", "background_image": "Háttérkép", + "background_media": "Háttérmédia", + "overlay_content": "Átfedéses tartalom hozzáadása", + "different_first_template": "Egyedi sablon az első dián", "media_fit": "Média méretezése", "size": "Méret", "chords": "Akkordok", @@ -684,6 +739,7 @@ "padding": "Belső margó", "special": "Különleges", "scrolling": "Görgetés", + "scrolling_speed": "Görgetési sebesség", "top_bottom": "Fentről lefelé", "bottom_top": "Lentről felfelé", "left_right": "Balról jobbra", @@ -762,6 +818,8 @@ "seconds": "Másodperc" }, "midi": { + "midi": "MIDI", + "activate": "Aktiválás MIDI jel alapján", "name": "Név", "input": "Bemenet", "output": "Kimenet", @@ -813,7 +871,9 @@ "font-size": "Betűméret", "zeros": "Nullák", "overrun": "Túlcsordulás színe", - "auto_stretch": "Tartalom automatikus nyújtása" + "source_output": "Forráskimenet", + "auto_stretch": "Tartalom automatikus nyújtása", + "labels": "Címkék megjelenítése" }, "settings": { "general": "Általános", @@ -821,11 +881,12 @@ "groups": "Csoportok", "styles": "Stílusok", "display_settings": "Kimenetek", - "actions": "Műveletek", "display": "Kijelző", "connection": "Kapcsolat", "cloud": "Felhő", "calendar": "Naptár", + "text_import": "Szöveg", + "media_import": "Média", "other": "Egyéb", "language": "Nyelv", "autosave": "Automatikus mentés", @@ -840,6 +901,7 @@ "select_display": "Kattintson a képernyőre, ahol meg szeretné jeleníteni a kimeneti ablakot.", "manual_input_hint": "Nem található a kijelző? Kattintson ide a helyzete kézi módosításához.", "manual_drag_hint": "Az aktív kimeneti ablak fölé történő manuális húzáshoz tartsa lenyomva a Ctrl/Cmd billentyűt.", + "allow_main_screen": "Kimenet engedélyezése a fő képernyőn", "identify_screens": "Képernyők azonosítása", "new_output": "Új kimenet", "enable_key_output": "Alfakulcs kimenet engedélyezése", @@ -851,7 +913,8 @@ "color_when_active": "Szín aktív állapotban", "fixed": "Fix", "lines": "Vonalak", - "override_with_template": "Stílus felülírása sablonnal", + "override_with_template": "Dia felülírása sablonnal", + "override_scripture_with_template": "Szentírás felülírása sablonnal", "active_layers": "Aktív rétegek", "window": "Ablak", "active_style": "Stílus alkalmazása", @@ -862,7 +925,10 @@ "full_colors": "Teljes diacsoport színek", "auto_output": "Kimeneti képernyő aktiválása indításkor", "hide_cursor_in_output": "Kurzor elrejtése a kimeneten", + "disable_presenter_controller_keys": "Bemutatóvezérlő billentyűk letiltása", "default_project_name": "Alapértelmezett projektnév", + "audio_fade_duration": "Halkítás időtartama", + "max_auto_font_size": "Maximális automatikus betűméret", "resolution": "Felbontás", "cropping": "Vágás", "frame_rate": "Képkockasebesség", @@ -875,9 +941,11 @@ "show_location": "Műsorok helye", "data_location": "Adatok helye", "user_data_location": "Felhasználói beállítások mentése az „Adatok helyére\"", + "popup_before_close": "Felugró ablak megjelenítése bezárás előtt", "font": "Betűkészlet", "font_family": "Betűkészlet család", "font_size": "Betűméret", + "border_radius": "Keret sugara", "colors": "Színek", "add_group": "Csoport hozzáadása", "group_shortcut": "Gyorsbillentyű a csoport aktiválásához", @@ -904,6 +972,7 @@ "preview_frame_rate": "Előnézet képkockasebessége", "auto": "Automatikus", "optimized": "Optimalizált", + "reduced": "Csökkentett", "full": "Teljes" }, "sort": { @@ -947,6 +1016,7 @@ "custom": "Vagy saját importálása", "max_verses": "Versek maximális száma diánként", "verse_numbers": "Versszámok", + "verses_on_individual_lines": "Versszakok az egyes sorokban", "version": "Verzió megjelenítése", "reference": "Hivatkozás megjelenítése", "combine_with_text": "Kombinálás a szöveggel", @@ -976,6 +1046,8 @@ "current_slide": "az aktuális dia számára", "text": "Szövegáttűnés", "media": "Médiaáttűnés", + "slide_transition": "Diaátmenet", + "background_transition": "Háttérátmenet", "duration": "Időtartam", "easing": "Enyhülés", "type": "Típus", diff --git a/public/lang/it.json b/public/lang/it.json index 74af8339..9e2ca20f 100644 --- a/public/lang/it.json +++ b/public/lang/it.json @@ -131,6 +131,10 @@ "media": { "_loop": "Loop", "play": "Avvia", + "play_multiple": "Avvio multiplo", + "toggle_shuffle": "Attiva/disattiva la riproduzione casuale", + "next": "prossima", + "previous": "Precedente", "play_no_filters": "Avvia senza filtri", "favourite": "Preferito", "pause": "Pausa", @@ -280,6 +284,8 @@ "effects": "Effetti", "scripture": "Scritture", "calendar": "Calendario", + "functions": "Funzioni", + "actions": "Azioni", "player": "Player", "live": "Live", "timers": "Timer", @@ -334,7 +340,6 @@ "change_name": "Cambia nome attivo", "choose_screen": "Scegli lo schermo", "change_output_values": "Modifica i valori di output", - "choose_style": "Scegli stile", "set_time": "Imposta orario", "animate": "Animate", "next_timer": "Timer diapositiva successiva", @@ -346,7 +351,7 @@ "edit_event": "Modifica evento", "about": "Informazioni", "history": "Cronologia", - "midi": "MIDI", + "action": "Azione", "connect": "Connetti", "cloud_update": "Sincronizzazione con il cloud", "cloud_method": "Posizione dei dati", @@ -382,7 +387,7 @@ "no_name": "Nessun nome", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Impossibile trovare alcun testo!", - "lyrics_copied": "Testi copiati da Genius!", + "lyrics_copied": "Testi copiati da ", "no_pdf_linux": "Impossibile esportare come PDF su Linux.", "one_output": "Devi avere almeno un output attivo!", "empty_cache": "La cache è vuota.", @@ -403,6 +408,7 @@ "variable": "New variable", "trigger": "Nuovo trigger", "audio_stream": "Nuovo flusso audio", + "playlist": "Nuova playlist", "category": "Nuova categoria", "private": "Nuova scena privata", "folder": "Nuova cartella", @@ -411,6 +417,7 @@ "template": "Nuovo modello", "scripture": "Nuova scrittura", "collection": "Nuova collezione", + "action": "Nuova azione", "event": "Nuovo evento" }, "show": { @@ -433,7 +440,6 @@ "remove_group": "Rimuovi gruppo", "choose_group": "Scegli gruppo", "goto_group": "Vai al gruppo", - "change_output_style": "Cambia stile di output", "active_outputs": "Output attivi", "all_outputs": "Tutti gli output", "specific_outputs": "Output specifici", @@ -475,6 +481,8 @@ "home": "Home", "mute": "Muta", "unmute": "Smuta", + "increase_volume": "Aumenta il volume", + "decrease_volume": "Diminuire il volume", "toggle_time_marker": "Attiva/disattiva gli indicatori di tempo", "add_time_marker": "Aggiungere indicatore di tempo", "bind_to": "Specifica output", @@ -505,12 +513,13 @@ "change_drawer_item": "Cambia elemento nel cassetto", "change_drawer_category": "Cambia categoria del cassetto", "toggle_drawer": "Attiva cassetto", - "actions": "Azioni", + "slide_actions": "Azioni diapositiva", "clear_history": "Pulisci cronologia", "set_key": "Imposta chiave", "custom_key": "Imposta valore personalizzato", - "play_on_midi": "Riproduci su ingresso MIDI", - "send_midi": "Invia MIDI in uscita", + "play_on_midi": "Attiva sul segnale MIDI", + "play_on_midi_tip": "Attiva questa diapositiva specifica quando ricevi il segnale MIDI scelto", + "send_midi": "Invia segnale MIDI", "delete_shows_not_indexed": "Elimina le scene nella cartella 'Shows' che non sono indicizzate", "delete_thumbnail_cache": "Elimina la cache delle miniature", "open_log_file": "Apri file di log", @@ -520,11 +529,31 @@ "next_after_media": "Avanti sui media finito", "remove_media": "Rimuovi media", "remove_layers": "Rimuovi livelli", + "start_recording": "Inizia a registrare", + "stop_recording": "Ferma registrazione", + "activate_on_startup": "Attiva all'avvio", "index_select_project": "Seleziona progetto per indice", - "index_select_project_show": "Seleziona l'elemento del progetto in base all'indice", + "next_project_item": "Elemento successivo del progetto", + "previous_project_item": "Elemento del progetto precedente", + "index_select_project_item": "Seleziona l'elemento del progetto in base all'indice", + "name_select_show": "Seleziona spettacolo per nome", "index_select_slide": "Seleziona la diapositiva per indice", - "start_recording": "Inizia a registrare", - "stop_recording": "Ferma registrazione" + "name_select_slide": "Seleziona la diapositiva per nome", + "toggle_output_lock": "Attiva/disattiva il blocco dell'uscita", + "toggle_output_windows": "Attiva/disattiva le finestre di output", + "id_select_group": "Seleziona il gruppo per ID", + "id_change_stage_layout": "Cambia il layout del palco per ID", + "index_select_overlay": "Seleziona la sovrapposizione per indice", + "name_select_overlay": "Seleziona la sovrapposizione per nome", + "change_volume": "Modifica volume", + "start_audio_stream": "Avvia il flusso audio", + "start_slide_timers": "Avvia i timer sulla diapositiva attiva", + "id_select_output_style": "Seleziona lo stile di output in base all'ID", + "change_output_style": "Cambia stile di output", + "change_transition": "Cambia transizione", + "change_variable": "Cambia variabile", + "start_trigger": "Avvia trigger", + "run_action": "Esegui l'azione" }, "animate": { "change": "cambia", @@ -551,7 +580,7 @@ "main_folder": "Imposta la cartella principale manualmente", "media_folder": "Cartella multimediale cloud", "reconnect": "Riconnetti", - "sync": "Sincronizza", + "sync": "Sincronizza ora", "choose_method_tip": "Ci sono dati esistenti nel cloud. Scegli di caricare da locale o scaricare da cloud. L'altra posizione verrà sovrascritta.", "local": "Locale", "syncing": "Sincronizzazione sul cloud", @@ -684,6 +713,7 @@ "padding": "Padding", "special": "Speciale", "scrolling": "Scorrimento", + "scrolling_speed": "Velocità di scorrimento", "top_bottom": "Dall'alto verso il basso", "bottom_top": "Dal basso verso l'alto", "left_right": "Da sinistra a destra", @@ -762,6 +792,8 @@ "seconds": "Secondi" }, "midi": { + "midi": "MIDI", + "activate": "Attivazione tramite segnale MIDI", "name": "Nome", "input": "Input", "output": "Output", @@ -821,7 +853,6 @@ "groups": "Gruppi", "styles": "Stili", "display_settings": "Output", - "actions": "Azioni", "display": "Schermo", "connection": "Connessione", "cloud": "Cloud", @@ -862,7 +893,10 @@ "full_colors": "Colori completi del gruppo di diapositive", "auto_output": "Attiva la schermata di output all'avvio", "hide_cursor_in_output": "Nascondi il cursore nell'output", + "disable_presenter_controller_keys": "Disattiva i tasti del controller del presentatore", "default_project_name": "Nome progetto predefinito", + "audio_fade_duration": "Durata della dissolvenza audio", + "max_auto_font_size": "Dimensione massima del carattere automatico", "resolution": "Risoluzione", "cropping": "Ritagliare", "frame_rate": "Frame rate", diff --git a/public/lang/no.json b/public/lang/no.json index db7b6254..f9fe892b 100644 --- a/public/lang/no.json +++ b/public/lang/no.json @@ -159,6 +159,13 @@ "online": "Online", "recommended": "Anbefalt" }, + "audio": { + "metronome": "Metronom", + "toggle_metronome": "Veksle metronom", + "tempo": "Tempo", + "bpm": "BPM", + "beats": "Slag" + }, "menu": { "show": "Presenter", "_title_show": "Fremvisning", @@ -314,6 +321,8 @@ "current": "Gjeldende", "global": "Globale", "toggle_global_group": "Veksle globale grupper", + "group_shortcut": "Snarvei for gruppe", + "group_template": "Mal for gruppe", "intro": "Intro", "verse": "Vers", "pre_chorus": "Pre-chorus", @@ -361,7 +370,7 @@ "manage_colors": "Endre farger", "choose_camera": "Velg kamera", "initialize": "Velkommen til FreeShow", - "unsaved": "Du har ikke lagret enda! Er du sikker på at du vil avslutte?", + "unsaved": "Er du sikkert på at du vil avslutte?", "cancel": "Avbryt", "continue": "Fortsett", "reset_all": "Tilbakestill alt", @@ -387,7 +396,7 @@ "no_name": "Ingen navn", "media_replaced": "Manglende mediefil erstattet med match.", "lyrics_undefined": "Kunne ikke finne noen sangtekster!", - "lyrics_copied": "Sangtekst kopiert fra Genius!", + "lyrics_copied": "Sangtekst kopiert fra ", "no_pdf_linux": "Kan ikke eksportere som PDF på Linux.", "one_output": "Du må ha minst en aktiv utgang!", "empty_cache": "Hurtigbuffer er tom.", @@ -431,7 +440,11 @@ "lyrics": "Tekstvisning", "text": "Tekstredigering", "update": "Oppdater show", - "slide_template": "Mal for lysbilde" + "slide_template": "Mal for lysbilde", + "search_results": "Søkeresultater", + "source": "Kilde", + "artist": "Artist", + "song": "Sang" }, "actions": { "rename": "Endre navn", @@ -514,6 +527,7 @@ "change_drawer_category": "Endre skuffe-kategori", "toggle_drawer": "Åpne/lukke skuffen", "slide_actions": "Handlinger for lysbilde", + "item_actions": "Handlinger for element", "clear_history": "Fjern historie", "set_key": "Sett toneart", "custom_key": "Skriv inn verdi", @@ -531,12 +545,12 @@ "remove_layers": "Fjern lag", "start_recording": "Start opptak", "stop_recording": "Stopp opptak", - "activate_on_startup": "Aktiver ved oppstart", "index_select_project": "Velg prosjekt fra indeks", "next_project_item": "Neste element i prosjekt", "previous_project_item": "Forrige element i prosjekt", "index_select_project_item": "Velg element i prosjekt fra indeks", "name_select_show": "Velg show fra navn", + "random_slide": "Spill av tilfeldig lysbilde", "index_select_slide": "Velg lysbilde fra indeks", "name_select_slide": "Velg lysbilde fra navn", "toggle_output_lock": "Veksle lås av utgang", @@ -547,13 +561,21 @@ "name_select_overlay": "Velg overlegg fra navn", "change_volume": "Endre volum", "start_audio_stream": "Start lydstrøm", + "start_playlist": "Start lysbilde", + "start_metronome": "Start metronom", "start_slide_timers": "Start tidtakere på aktivt lysbilde", "id_select_output_style": "Velg utgangsstil fra ID", "change_output_style": "Endre stil for utgangsskjerm", "change_transition": "Endre overgang", "change_variable": "Endre variabel", "start_trigger": "Start utløser", - "run_action": "Kjør handling" + "run_action": "Kjør handling", + "custom_activation": "Egendefinert aktivering", + "activate_on_startup": "Aktiver ved oppstart", + "activate_save": "Aktiver ved lagring", + "activate_slide_clicked": "Aktiver ved klikk på lysbilde", + "activate_scripture_start": "Aktiver når bibeltekst vises", + "activate_show_created": "Aktiver når show blir laget" }, "animate": { "change": "Endre", @@ -619,6 +641,7 @@ "createNew": "Lag ny", "selectAll": "Merk alt", "force_outputs": "Tving visning av skjermer", + "align_with_screen": "Juster til skjerm", "toggle_output": "Veksle utgangsskjerm", "move_to_front": "Flytt fremst", "lock_to_output": "Lås til utgang", @@ -656,6 +679,9 @@ "background_color": "Bakgrunnsfarge", "background_opacity": "Bakgrunnsgjennomsiktighet", "background_image": "Bakgrunnsbilde", + "background_media": "Bakgrunnsmedia", + "overlay_content": "Legg til innhold fra overlegg", + "different_first_template": "Egen mal på første lysbilde", "media_fit": "Tilpass medier", "size": "Størrelse", "chords": "Akkorder", @@ -845,7 +871,9 @@ "font-size": "Tekststørrelse", "zeros": "Null", "overrun": "Utløpt Farge", - "auto_stretch": "Strekk ut innhold automatisk" + "source_output": "Utgangskilde", + "auto_stretch": "Strekk ut innhold automatisk", + "labels": "Vis etiketter" }, "settings": { "general": "Generelt", @@ -857,6 +885,8 @@ "connection": "Tilkobling", "cloud": "Sky", "calendar": "Kalender", + "text_import": "Tekst", + "media_import": "Media", "other": "Annet", "language": "Språk", "autosave": "Lagre automatisk", @@ -871,6 +901,7 @@ "select_display": "Trykk på skjermen hvor du vil vise utgangskjermen.", "manual_input_hint": "Finner ikke skjermen? Trykk her for å endre plasseringen manuelt.", "manual_drag_hint": "Du kan også holde ctrl/cmd over en aktiv utgangsskjerm for å flytte den manuelt.", + "allow_main_screen": "Tillat utgang på hovedskjerm", "identify_screens": "Identifiser skjermer", "new_output": "Ny utgang", "enable_key_output": "Aktiver 'alpha key'-utgang", @@ -882,7 +913,8 @@ "color_when_active": "Farge når aktiv", "fixed": "Fikset", "lines": "Linjer", - "override_with_template": "Overskriv stil med mal", + "override_with_template": "Overskriv lysbilde med mal", + "override_scripture_with_template": "Overskriv bibeltekst med mal", "active_layers": "Aktive lag", "window": "Vindu", "active_style": "Bruk stil", @@ -909,9 +941,11 @@ "show_location": "Show-plassering", "data_location": "Data-plassering", "user_data_location": "Lagre bruker-innstillinger i 'Data-plassering'", + "popup_before_close": "Alltid vis popup før lukking", "font": "Skrift", "font_family": "Skrifttype", "font_size": "Skriftstørrelse", + "border_radius": "Hjørneradius", "colors": "Farger", "add_group": "Legg til gruppe", "group_shortcut": "Shortcut to activate group", @@ -938,6 +972,7 @@ "preview_frame_rate": "Bildefrekvens for forhåndsvisning", "auto": "Auto", "optimized": "Optimalisert", + "reduced": "Redusert", "full": "Full" }, "sort": { @@ -981,6 +1016,7 @@ "custom": "Eller importer dine egene", "max_verses": "Maks vers per lysbilde", "verse_numbers": "Versnummer", + "verses_on_individual_lines": "Vers på enkeltlinjer", "version": "Vis versjon", "reference": "Vis referanse", "combine_with_text": "Kombiner med tekst", @@ -1010,6 +1046,8 @@ "current_slide": "for valgt lysbilde", "text": "Tekstovergang", "media": "Medieovergang", + "slide_transition": "Overgang for lysbilde", + "background_transition": "Overgang for bakgrunn", "duration": "Varighet", "easing": "Bevegelse", "type": "Type", diff --git a/public/lang/pl.json b/public/lang/pl.json index d91c202c..ae013593 100644 --- a/public/lang/pl.json +++ b/public/lang/pl.json @@ -382,7 +382,7 @@ "no_name": "Brak nazwy", "media_replaced": "Brakujący plik zastąpiony pasującym.", "lyrics_undefined": "Nie znaleziono słów do pieśni!", - "lyrics_copied": "Słowa pieśni skopiowano z Genius!", + "lyrics_copied": "Słowa pieśni skopiowano z ", "no_pdf_linux": "Nie można wyeksportować PDF na Linuksie", "one_output": "Musisz mieć co najmniej jedno aktywne wyjście!", "empty_cache": "Pamięć podręczna jest pusta.", diff --git a/public/lang/pt_BR.json b/public/lang/pt_BR.json index aa78665d..1a412cdf 100644 --- a/public/lang/pt_BR.json +++ b/public/lang/pt_BR.json @@ -372,7 +372,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", diff --git a/public/lang/ru.json b/public/lang/ru.json index bd1307f8..c9729e95 100644 --- a/public/lang/ru.json +++ b/public/lang/ru.json @@ -382,7 +382,7 @@ "no_name": "Нет имени", "media_replaced": "Отсутствующий файл мультимедиа заменен на совпадение.", "lyrics_undefined": "Не могу найти слова!", - "lyrics_copied": "Текст песни скопирован с Genius!", + "lyrics_copied": "Текст песни скопирован с ", "no_pdf_linux": "Невозможно экспортировать как PDF в Linux.", "one_output": "У вас должен быть хотя бы один активный выход!", "empty_cache": "Кэш пуст.", diff --git a/public/lang/sk.json b/public/lang/sk.json index 0a9e4a14..41fd4942 100644 --- a/public/lang/sk.json +++ b/public/lang/sk.json @@ -372,7 +372,7 @@ "no_name": "Žiadny názov", "media_replaced": "Chýbajúci media súbor nahradený iným.", "lyrics_undefined": "Nenašli sa žiadne texty!", - "lyrics_copied": "Texty skopírované z Genius!", + "lyrics_copied": "Texty skopírované z ", "no_pdf_linux": "Nedá sa exportovať PDF na Linuxe.", "one_output": "Musíte mať aspoň jeden aktívny výstup!", "empty_cache": "Cache je prázdna.", diff --git a/public/lang/sr.json b/public/lang/sr.json index 2190b853..633cebb5 100644 --- a/public/lang/sr.json +++ b/public/lang/sr.json @@ -372,7 +372,7 @@ "no_name": "No name", "media_replaced": "Missing media file replaced with match.", "lyrics_undefined": "Could not find any lyrics!", - "lyrics_copied": "Lyrics copied from Genius!", + "lyrics_copied": "Lyrics copied from ", "no_pdf_linux": "Can't export as PDF on Linux.", "one_output": "You have to have at least one active output!", "empty_cache": "Cache is empty.", diff --git a/public/lang/ua.json b/public/lang/ua.json index 60d204f1..e55fae6f 100644 --- a/public/lang/ua.json +++ b/public/lang/ua.json @@ -372,7 +372,7 @@ "no_name": "Без імені", "media_replaced": "Відсутній мультимедійний файл замінено відповідним.", "lyrics_undefined": "Не вдалося знайти тексти!", - "lyrics_copied": "Текст пісні скопійовано з Genius!", + "lyrics_copied": "Текст пісні скопійовано з ", "no_pdf_linux": "Неможливо експортувати як PDF у Linux.", "one_output": "Ви повинні мати принаймні один активний вихід!", "empty_cache": "Кеш порожній.", diff --git a/rollup.config.mjs b/rollup.config.mjs index 6246045a..fa8cef04 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -7,10 +7,20 @@ import css from "rollup-plugin-css-only" import livereload from "rollup-plugin-livereload" import serve from "rollup-plugin-serve" import svelte from "rollup-plugin-svelte" +import startSvelteInspector from "svelte-inspector" import sveltePreprocess from "svelte-preprocess" const production = !process.env.ROLLUP_WATCH +// SVELTE INSPECTOR CONFIG +// https://github.com/qutran/svelte-inspector +const inspectorConfig = { + activateKeyCode: 73, // I(nspect) + openFileKeyCode: 79, // O(pen) + editor: "code", // Allowed values: 'sublime', 'atom', 'code', 'webstorm', 'phpstorm', 'idea14ce', 'vim', 'emacs', 'visualstudio' + color: "#ff3c00", +} + export default [ mainApp(), webServer("remote", { typescript: true }), @@ -42,6 +52,7 @@ function mainApp() { }, onwarn: handleWarnings, }), + !production && startSvelteInspector(inspectorConfig), // extract any component CSS out into a separate file - better for performance css({ output: "bundle.css", diff --git a/scripts/cleanBuilds.js b/scripts/cleanBuilds.js index 3b6a4ae4..f902e5d9 100644 --- a/scripts/cleanBuilds.js +++ b/scripts/cleanBuilds.js @@ -8,6 +8,8 @@ const buildElectronPath = join(__dirname, "..", "build") // this includes server // delete folders and all of it's content deleteFolderRecursive(buildSveltePath) deleteFolderRecursive(buildElectronPath) +deletePublicFile("preload.ts") +deletePublicFile("preload.js.map") function deleteFolderRecursive(folderPath) { if (!existsSync(folderPath)) return @@ -23,3 +25,11 @@ function deleteFolderRecursive(folderPath) { rmdirSync(folderPath) } + +function deletePublicFile(fileName) { + const publicPath = join(__dirname, "..", "public") + const filePath = join(publicPath, fileName) + if (!existsSync(filePath)) return + + unlinkSync(filePath) +} diff --git a/scripts/electronDevPostBuild.js b/scripts/electronDevPostBuild.js new file mode 100644 index 00000000..55d13b50 --- /dev/null +++ b/scripts/electronDevPostBuild.js @@ -0,0 +1,26 @@ +const { readFileSync, writeFileSync, copyFile } = require("fs") +const { join } = require("path") + +function movePreload() { + const rootPath = join(__dirname, "..") + + // copy preload.ts to public + const preloadPath = join(rootPath, "src", "electron", "preload.ts") + const newPreloadPath = join(rootPath, "public", "preload.ts") + copyFile(preloadPath, newPreloadPath, (err) => { + if (err) console.error("Error copying preload file:", err) + }) + + // change source to new preload path, and write built map to public + const preloadMapPath = join(rootPath, "build", "electron", "preload.js.map") + const preloadMapContent = readFileSync(preloadMapPath, "utf8") + const parsedPreloadMap = JSON.parse(preloadMapContent || "{}") + + if (!parsedPreloadMap.sources) return + parsedPreloadMap.sources = ["preload.ts"] + + const newPreloadMapPath = join(rootPath, "public", "preload.js.map") + writeFileSync(newPreloadMapPath, JSON.stringify(parsedPreloadMap)) +} + +if (process.env.NODE_ENV !== "production") movePreload() diff --git a/src/electron/cloud/drive.ts b/src/electron/cloud/drive.ts index 6a50f024..fb029c36 100644 --- a/src/electron/cloud/drive.ts +++ b/src/electron/cloud/drive.ts @@ -3,7 +3,7 @@ import path from "path" import { isProd, toApp } from ".." import { STORE } from "../../types/Channels" import { stores } from "../data/store" -import { checkShowsFolder, dataFolderNames, deleteFile, getDataFolder, getFileStats, loadShows, readFile, writeFile } from "../utils/files" +import { checkShowsFolder, dataFolderNames, deleteFile, doesPathExist, getDataFolder, getFileStats, loadShows, readFile, writeFile } from "../utils/files" import { trimShow } from "../utils/responses" let driveClient: any = null @@ -169,6 +169,7 @@ const SHOWS_CONTENT = "SHOWS_CONTENT" const storesToSave = ["EVENTS", "OVERLAYS", "PROJECTS", "SYNCED_SETTINGS", "STAGE_SHOWS", "TEMPLATES", "THEMES", "MEDIA"] // don't upload: settings.json, config.json, cache.json, history.json +export let currentlyDeletedShows: string[] = [] export async function syncDataDrive(data: any) { let files = await listFiles(20, "'" + data.mainFolderId + "' in parents") if (files === null) return { error: "Error: Could not get files! Have you shared a folder with the service account?" } @@ -371,8 +372,36 @@ export async function syncDataDrive(data: any) { } } - if ((newest === "cloud" || data.method === "download") && cloudContent) allShows[id] = driveContent[id] - else if (localContent) { + if ((newest === "cloud" || data.method === "download") && cloudContent) { + // deleted locally + if (currentlyDeletedShows.includes(id)) { + allShows[id] = { deleted: true, name: driveContent[id]?.name || "" } + + delete shows[id] + uploadCount++ + if (DEBUG) console.log("Show has been deleted from cloud:", driveContent[id]?.name || "") + + return + } + + allShows[id] = driveContent[id] + + // if a show is deleted, synced and undone it can't be restored unless a duplicate is created with new ID! + // but it can be restored when deleted before it's synced + // deleted in cloud + if (driveContent[id].deleted === true) { + let p: string = path.join(showsPath, name) + if (doesPathExist(p)) { + deleteFile(p) + downloadCount++ + if (DEBUG) console.log("Show has been deleted locally:", driveContent[id]?.name || "") + } + + delete shows[id] + + return + } + } else if (localContent) { try { allShows[id] = JSON.parse(localContent)[1] } catch (err) { @@ -383,9 +412,6 @@ export async function syncDataDrive(data: any) { if (cloudContent && localContent === cloudContent) return - // TODO: deleted files are not deleted in cloud - // popup: found some show files not in the cloud: sync(upload) or delete - // "download" show if (cloudContent && (newest === "cloud" || data.method === "download") && data.method !== "upload") { let newName = (show?.name || id) + ".show" @@ -434,6 +460,7 @@ export async function syncDataDrive(data: any) { if (DEBUG) console.log("Drive shows:", Object.keys(allShows).length) let file = createFile(data.mainFolderId, { type: "json", name }, JSON.stringify(allShows)) let response = await uploadFile(file, driveFileId) + currentlyDeletedShows = [] if (response?.status != 200) { changes.push({ type: "show", action: "upload_failed", name }) diff --git a/src/electron/data/defaults.ts b/src/electron/data/defaults.ts index 273d254d..e2d8c1b7 100644 --- a/src/electron/data/defaults.ts +++ b/src/electron/data/defaults.ts @@ -74,11 +74,11 @@ export const defaultSettings: { [key in SaveListSettings]: any } = { text: { type: "fade", duration: 500, easing: "sine" }, media: { type: "fade", duration: 800, easing: "sine" }, }, - os: { platform: "", name: "Computer" }, volume: 1, gain: 1, driveData: { mainFolderId: null, disabled: false, initializeMethod: null, disableUpload: false }, calendarAddShow: "", + metronome: {}, special: {}, } diff --git a/src/electron/data/downloadMedia.ts b/src/electron/data/downloadMedia.ts index 158f8b38..cdc3fceb 100644 --- a/src/electron/data/downloadMedia.ts +++ b/src/electron/data/downloadMedia.ts @@ -4,6 +4,7 @@ import path from "path" import { toApp } from ".." import { MAIN } from "../../types/Channels" import { dataFolderNames, doesPathExist, getDataFolder } from "../utils/files" +import { waitUntilValueIsDefined } from "../utils/helpers" export function downloadMedia(lessons: any[]) { let replace = lessons.map(checkLesson) @@ -12,6 +13,10 @@ export function downloadMedia(lessons: any[]) { } function checkLesson(lesson: any) { + downloadCount = 0 + failedDownloads = 0 + toApp(MAIN, { channel: "LESSONS_DONE", data: { showId: lesson.showId, status: { finished: 0, failed: 0 } } }) + const lessonsFolder = getDataFolder(lesson.path, dataFolderNames.lessons) const lessonFolder = path.join(lessonsFolder, lesson.name) fs.mkdirSync(lessonFolder, { recursive: true }) @@ -21,7 +26,7 @@ function checkLesson(lesson: any) { let filePath = getFilePath(file) if (!filePath) return - return downloadFile(filePath, file) + return downloadFile(filePath, file, lesson.showId) }) .filter((a: any) => a) @@ -45,56 +50,92 @@ function getFileExtension(url: string, fileType: string = "") { return "" } -function downloadFile(filePath: string, file: any) { +function downloadFile(filePath: string, file: any, showId: string) { let fileRef = { from: file.url, to: filePath, type: file.type } if (doesPathExist(filePath)) { // console.log(filePath + " exists!") + downloadCount++ + toApp(MAIN, { channel: "LESSONS_DONE", data: { showId, status: { finished: downloadCount, failed: failedDownloads } } }) return fileRef } - addToDownloadQueue({ path: filePath, file }) + addToDownloadQueue({ path: filePath, file, showId }) return fileRef } let downloadQueue: any[] = [] function addToDownloadQueue(file: any) { + let alreadyInQueue = downloadQueue.find((a) => a.path === file.path) + if (alreadyInQueue) { + downloadCount++ + return + } + downloadQueue.push(file) - startDownload() + initDownload() } -let downloading: any = null -let downloadCount: number = 0 -let errorCount: number = 0 -async function startDownload() { - if (downloading) return +let currentlyDownloading: number = 0 +const maxAmount = 5 +const refillMargin = maxAmount * 0.6 +let waiting: boolean = false +async function initDownload() { + if (waiting) return + if (!downloadQueue.length) { - if (downloadCount) console.log(`${downloadCount} file(s) downloaded!`) + if (currentlyDownloading < 1 && downloadCount) { + console.log(`${downloadCount} file(s) downloaded!`) + downloadCount = 0 + failedDownloads = 0 + + return + } + + // return setTimeout(initDownload, 500) return } - let timeout = true - setTimeout(() => { - if (!timeout) return - next() - }, 8000) + // generate a max amount at the same time + if (currentlyDownloading > maxAmount) { + waiting = true + await waitUntilValueIsDefined(() => currentlyDownloading < refillMargin) + waiting = false + } + + if (!downloadQueue[0]) { + downloadQueue.shift() + initDownload() + return + } - downloading = downloadQueue.shift() + currentlyDownloading++ + startDownload(downloadQueue.shift()) +} +let downloadCount: number = 0 +let failedDownloads: number = 0 +let errorCount: number = 0 +async function startDownload(downloading: any) { // download the media - const fileStream = fs.createWriteStream(downloading.path) const file = downloading.file let url = file.url if (!url) return next() + const fileStream = fs.createWriteStream(downloading.path) console.log(`Downloading lessons media: ${file.name}`) https .get(url, (res) => { if (res.statusCode !== 200) { - console.error(`Failed to download file, status code: ${res.statusCode}`) + fileStream.close() fs.unlink(downloading.path, () => {}) + + console.error(`Failed to download file, status code: ${res.statusCode}`) + failedDownloads++ + toApp(MAIN, { channel: "LESSONS_DONE", data: { showId: downloading.showId, status: { finished: downloadCount, failed: failedDownloads } } }) + next() return } @@ -102,42 +143,58 @@ async function startDownload() { res.pipe(fileStream) res.on("error", (err) => { + fileStream.close() console.log(`Response error: ${err.message}`) + retry() }) fileStream.on("error", (err) => { fs.unlink(downloading.path, () => {}) console.error(`File error: ${err.message}`) + retry() }) fileStream.on("finish", () => { fileStream.close() downloadCount++ + console.error(`Finished downloading file: ${file.name}`) + toApp(MAIN, { channel: "LESSONS_DONE", data: { showId: downloading.showId, status: { finished: downloadCount, failed: failedDownloads } } }) next() }) }) .on("error", (err) => { - fs.unlink(downloading.path, () => {}) + fileStream.close() console.error(`Request error: ${err.message}`) + retry() }) function next() { - if (!timeout) return - - timeout = false - downloading = null - startDownload() + clearTimeout(timeout) + currentlyDownloading-- + initDownload() } function retry() { - if (errorCount > 5) return + if (errorCount > 5) { + failedDownloads++ + toApp(MAIN, { channel: "LESSONS_DONE", data: { showId: downloading.showId, status: { finished: downloadCount, failed: failedDownloads } } }) + + next() + return + } errorCount++ - next() addToDownloadQueue(downloading) + next() } + + let timeout = setTimeout(() => { + fileStream.close() + console.error(`File timed out: ${file.name}`) + next() + }, 60 * 8 * 1000) // 8 minutes timeout } diff --git a/src/electron/data/export.ts b/src/electron/data/export.ts index 5c269e8e..f5f68afe 100644 --- a/src/electron/data/export.ts +++ b/src/electron/data/export.ts @@ -7,8 +7,54 @@ import fs from "fs" import { join } from "path" import { EXPORT, MAIN, STARTUP } from "../../types/Channels" import { isProd, toApp } from "../index" -import { doesPathExist } from "../utils/files" +import { dataFolderNames, doesPathExist, getDataFolder, openSystemFolder, selectFolderDialog } from "../utils/files" import { exportOptions } from "../utils/windowOptions" +import { Message } from "../../types/Socket" + +// SHOW: .show, PROJECT: .project, BIBLE: .fsb +const customJSONExtensions: any = { + TEMPLATE: ".fstemplate", + THEME: ".fstheme", +} + +export function startExport(_e: any, msg: Message) { + let dataPath: string = msg.data.path + + if (!dataPath) { + dataPath = selectFolderDialog() + if (!dataPath) return + + toApp(MAIN, { channel: "DATA_PATH", data: dataPath }) + } + + msg.data.path = getDataFolder(dataPath, dataFolderNames.exports) + + let customExt = customJSONExtensions[msg.channel] + if (customExt) { + exportJSON(msg.data.content, customExt, msg.data.path) + return + } + + if (msg.channel !== "GENERATE") return + + if (msg.data.type === "pdf") createPDFWindow(msg.data) + else if (msg.data.type === "txt") exportTXT(msg.data) + else if (msg.data.type === "project") exportProject(msg.data) +} + +// only open once per session +let systemOpened: boolean = false +function doneWritingFile(err: any, exportFolder: string) { + let msg: string = "export.exported" + + // open export location in system when completed + if (!err && !systemOpened) { + openSystemFolder(exportFolder) + systemOpened = true + } else msg = err + + toApp(MAIN, { channel: "ALERT", data: msg }) +} // ----- PDF ----- @@ -63,19 +109,18 @@ ipcMain.on(EXPORT, (_e, msg: any) => { if (msg.data.type === "pdf") generatePDF(join(msg.data.path, msg.data.name)) }) +// ----- JSON ----- + +export function exportJSON(content: any, extension: string, path: string) { + writeFile(join(path, content.name || "Unnamed"), extension, JSON.stringify(content, null, 4), "utf-8", (err: any) => doneWritingFile(err, path)) +} + // ----- TXT ----- export function exportTXT(data: any) { - let msg: string = "export.exported" data.shows.forEach((show: any) => { - writeFile(join(data.path, show.name), ".txt", getSlidesText(show), "utf-8", doneWritingFile) + writeFile(join(data.path, show.name), ".txt", getSlidesText(show), "utf-8", (err: any) => doneWritingFile(err, data.path)) }) - - function doneWritingFile(err: any) { - if (err) msg = err - - toApp(MAIN, { channel: "ALERT", data: msg }) - } } // WIP do this in frontend diff --git a/src/electron/data/import.ts b/src/electron/data/import.ts index 07ca77c5..445c2c42 100644 --- a/src/electron/data/import.ts +++ b/src/electron/data/import.ts @@ -111,7 +111,7 @@ export async function importShow(id: any, files: string[] | null, dataPath: stri let importId = id let sqliteFile = id === "openlp" && files.find((a) => a.includes(".sqlite")) if (sqliteFile) files = files.filter((a) => a.includes(".sqlite")) - if (id === "easyworship" || sqliteFile) importId = "sqlite" + if (id === "easyworship" || id === "softprojector" || sqliteFile) importId = "sqlite" let data: any[] = [] if (specialImports[importId]) data = await specialImports[importId](files, dataPath) diff --git a/src/electron/data/store.ts b/src/electron/data/store.ts index 4a9d9d17..b3ed8bea 100644 --- a/src/electron/data/store.ts +++ b/src/electron/data/store.ts @@ -27,35 +27,40 @@ const fileNames: { [key: string]: string } = { // NOTE: defaults will always replace the keys with any in the default when they are removed +const storeExtraConfig: { [key: string]: string } = {} +if (process.env.FS_MOCK_STORE_PATH != undefined) { + storeExtraConfig["cwd"] = process.env.FS_MOCK_STORE_PATH +} + // MAIN WINDOW -export const config = new Store({ defaults: defaultConfig }) +export const config = new Store({ defaults: defaultConfig, ...storeExtraConfig }) // ERROR LOG -export const error_log = new Store({ name: fileNames.error_log, defaults: {} }) +export const error_log = new Store({ name: fileNames.error_log, defaults: {}, ...storeExtraConfig }) // SETTINGS -const settings = new Store({ name: fileNames.settings, defaults: defaultSettings }) -let synced_settings = new Store({ name: fileNames.synced_settings, defaults: defaultSyncedSettings }) -let themes = new Store({ name: fileNames.themes, defaults: {} }) +const settings = new Store({ name: fileNames.settings, defaults: defaultSettings, ...storeExtraConfig }) +let synced_settings = new Store({ name: fileNames.synced_settings, defaults: defaultSyncedSettings, ...storeExtraConfig }) +let themes = new Store({ name: fileNames.themes, defaults: {}, ...storeExtraConfig }) // PROJECTS -let projects = new Store({ name: fileNames.projects, defaults: { projects: {}, folders: {} } }) +let projects = new Store({ name: fileNames.projects, defaults: { projects: {}, folders: {} }, ...storeExtraConfig }) // SLIDES -let shows = new Store({ name: fileNames.shows, defaults: {} }) -let stageShows = new Store({ name: fileNames.stageShows, defaults: {} }) -let overlays = new Store({ name: fileNames.overlays, defaults: {} }) -let templates = new Store({ name: fileNames.templates, defaults: {} }) +let shows = new Store({ name: fileNames.shows, defaults: {}, ...storeExtraConfig }) +let stageShows = new Store({ name: fileNames.stageShows, defaults: {}, ...storeExtraConfig }) +let overlays = new Store({ name: fileNames.overlays, defaults: {}, ...storeExtraConfig }) +let templates = new Store({ name: fileNames.templates, defaults: {}, ...storeExtraConfig }) // CALENDAR -let events = new Store({ name: fileNames.events, defaults: {} }) +let events = new Store({ name: fileNames.events, defaults: {}, ...storeExtraConfig }) // CLOUD -let driveKeys = new Store({ name: fileNames.driveKeys, defaults: {} }) +let driveKeys = new Store({ name: fileNames.driveKeys, defaults: {}, ...storeExtraConfig }) // CACHE -const media = new Store({ name: fileNames.media, defaults: {}, accessPropertiesByDotNotation: false }) -const cache = new Store({ name: fileNames.cache, defaults: {} }) -let history = new Store({ name: fileNames.history, defaults: {} }) +const media = new Store({ name: fileNames.media, defaults: {}, accessPropertiesByDotNotation: false, ...storeExtraConfig }) +const cache = new Store({ name: fileNames.cache, defaults: {}, ...storeExtraConfig }) +let history = new Store({ name: fileNames.history, defaults: {}, ...storeExtraConfig }) export let stores: { [key: string]: Store } = { SETTINGS: settings, diff --git a/src/electron/data/thumbnails.ts b/src/electron/data/thumbnails.ts new file mode 100644 index 00000000..683f441c --- /dev/null +++ b/src/electron/data/thumbnails.ts @@ -0,0 +1,305 @@ +import { NativeImage, ResizeOptions, app, nativeImage } from "electron" +import fs from "fs" +import path from "path" +import { isProd, toApp } from ".." +import { MAIN } from "../../types/Channels" +import { doesPathExist } from "../utils/files" +import { waitUntilValueIsDefined } from "../utils/helpers" +import { defaultSettings } from "./defaults" + +export function getThumbnail(data: any) { + let output = createThumbnail(data.input, data.size || 500) + + return { ...data, output } +} + +export function createThumbnail(filePath: string, size: number = 250) { + if (!filePath) return "" + + let outputPath = getThumbnailPath(filePath, size) + + addToGenerateQueue({ input: filePath, output: outputPath, size }) + + return outputPath +} + +type Thumbnail = { input: string; output: string; size: number } +let thumbnailQueue: Thumbnail[] = [] +function addToGenerateQueue(data: Thumbnail) { + thumbnailQueue.push(data) + nextInQueue() +} + +let working = false +function nextInQueue() { + if (working || !thumbnailQueue[0]) return + // if (!thumbnailQueue[0]) return removeCaptureWindow() + + working = true + generateThumbnail(thumbnailQueue.shift()!) +} + +function generationFinished() { + working = false + nextInQueue() +} + +let exists: string[] = [] +async function generateThumbnail(data: Thumbnail) { + if (isProd && exists.includes(data.output)) return generationFinished() + if (doesPathExist(data.output)) { + exists.push(data.output) + generationFinished() + return + } + + try { + await generate(data.input, data.output, data.size + "x?", { seek: 0.5 }) + } catch (err) { + console.error(err) + generationFinished() + } +} + +let thumbnailFolderPath: string = "" +function getThumbnailFolderPath() { + if (thumbnailFolderPath) return thumbnailFolderPath + + let p: string = path.join(app.getPath("temp"), "freeshow-cache") + if (!doesPathExist(p)) fs.mkdirSync(p, { recursive: true }) + thumbnailFolderPath = p + + return p +} + +function getThumbnailPath(filePath: string, size: number = 250) { + let folderPath = thumbnailFolderPath || getThumbnailFolderPath() + return path.join(folderPath, `${hashCode(filePath)}-${size}.jpg`) +} + +function hashCode(str: string) { + let hash = 0 + + for (let i = 0; i < str.length; i++) { + let chr = str.charCodeAt(i) + hash = (hash << 5) - hash + chr // bit shift + hash |= 0 // convert to 32bit integer + } + + if (hash < 0) return "i" + hash.toString().slice(1) + return "a" + hash.toString() +} + +///// CUSTOM WINDOW ///// + +// const JS_IMAGE = 'document.querySelector("img")' +// const JS_IMAGE_LOADED = JS_IMAGE + "?.complete" +// const JS_IMAGE_WIDTH = JS_IMAGE + ".naturalWidth" +// const JS_IMAGE_HEIGHT = JS_IMAGE + ".naturalHeight" + +// const JS_VIDEO = 'document.querySelector("video")' +// const JS_VIDEO_READY = JS_VIDEO + "?.readyState" +// const JS_GET_DURATION = JS_VIDEO + ".duration" +// const JS_PAUSE_VIDEO = JS_VIDEO + ".pause()" +// const JS_REMOVE_CONTROLS = JS_VIDEO + '.removeAttribute("controls")' +// const JS_SET_TIME = JS_VIDEO + ".currentTime=" +// const JS_VIDEO_WIDTH = JS_VIDEO + ".videoWidth" +// const JS_VIDEO_HEIGHT = JS_VIDEO + ".videoHeight" + +// let captureWindow: BrowserWindow | null = null +// async function createCaptureWindow(data: any) { +// if (!captureWindow) captureWindow = new BrowserWindow(captureOptions) + +// captureWindow?.loadFile(data.input) + +// // wait until loaded +// let contentLoaded: boolean = false +// captureWindow?.once("ready-to-show", () => { +// contentLoaded = true +// }) +// await waitUntilValueIsDefined(() => contentLoaded, 20, 1000) +// if (!contentLoaded) return exit() + +// let extension = getExtension(data.input) +// if (customImageCapture.includes(extension)) return checkImage() + +// let videoLoaded: any = await waitUntilValueIsDefined(checkIfVideoHasLoaded, 20, 2000) +// if (!videoLoaded) { +// // probably unsupported codec +// if (extension === "mp4") return exit() +// return checkImage() +// } + +// let videoDuration = await captureWindow?.webContents.executeJavaScript(JS_GET_DURATION) +// if (!videoDuration) return exit() + +// let seekTo = Math.floor(videoDuration) * (data.seek ?? 0.5) + +// captureWindow?.webContents.executeJavaScript(JS_PAUSE_VIDEO) +// captureWindow?.webContents.executeJavaScript(JS_REMOVE_CONTROLS) +// captureWindow?.webContents.executeJavaScript(JS_SET_TIME + seekTo) + +// // check if video seek has loaded properly +// await waitUntilValueIsDefined(checkIfVideoHasLoaded, 20) + +// let videoWidth = await captureWindow?.webContents.executeJavaScript(JS_VIDEO_WIDTH) +// let videoHeight = await captureWindow?.webContents.executeJavaScript(JS_VIDEO_HEIGHT) + +// await setWindowSize(videoWidth, videoHeight) + +// captureContent() + +// async function checkImage() { +// let hasLoaded: any = await waitUntilValueIsDefined(checkIfImageHasLoaded, 20, 2000) +// if (!hasLoaded) return exit() + +// let imageWidth = await captureWindow?.webContents.executeJavaScript(JS_IMAGE_WIDTH) +// let imageHeight = await captureWindow?.webContents.executeJavaScript(JS_IMAGE_HEIGHT) + +// await setWindowSize(imageWidth, imageHeight) + +// captureContent() +// return +// } + +// async function setWindowSize(contentWidth: number, contentHeight: number) { +// if (!contentWidth) contentWidth = 1920 +// if (!contentHeight) contentHeight = 1080 + +// const ratio = contentWidth / contentHeight +// let width = data.size?.width +// let height = data.size?.height +// if (!width) width = height ? Math.floor(height * ratio) : contentWidth +// if (!height) height = data.size?.width ? Math.floor(width / ratio) : contentHeight + +// captureWindow?.setSize(width, height) + +// // wait for window and content to get resized +// await wait(50) +// } + +// async function checkIfImageHasLoaded() { +// let imageComplete = await captureWindow?.webContents.executeJavaScript(JS_IMAGE_LOADED) +// return imageComplete +// } + +// async function checkIfVideoHasLoaded() { +// let readyState = await captureWindow?.webContents.executeJavaScript(JS_VIDEO_READY) +// return readyState === 4 +// } + +// async function captureContent() { +// let image = await captureWindow?.webContents.capturePage() +// if (!image) return exit() + +// removeCaptureWindow() +// saveToDisk(data.output, image) +// } + +// function exit() { +// generationFinished() +// } +// } +// function removeCaptureWindow() { +// if (!captureWindow) return +// if (captureWindow.isDestroyed()) { +// captureWindow = null +// return +// } + +// captureWindow.on("closed", () => (captureWindow = null)) +// captureWindow.destroy() +// } + +///// GENERATE ///// + +interface Config { + seek?: number // 0-1 + // format?: "jpg" | "jpeg" | "png" + // quality?: number // 0-100 +} +const customImageCapture = ["gif", "webp"] +async function generate(input: string, output: string, size: string, config: Config = {}) { + if (!input || !output) { + generationFinished() + return + } + + const parsedSize = parseSize(size) + let extension = getExtension(input) + + // // mov files can't be opened directly, but can be played as video elem + // if (extension === "mov") return captureWithCanvas({ input, output, size: parsedSize, config }) + // // capture images directly from electron nativeImage (fastest) + // if (!customImageCapture.includes(extension) && defaultSettings.imageExtensions.includes(extension)) return captureImage(input, output, parsedSize) + // // capture videos in custom window (to reduce load on main window) + // createCaptureWindow({ input, output, size: parsedSize, config }) // WIP this would many times create a save dialog window in some cases + + // capture images directly from electron nativeImage (fastest) + if (!customImageCapture.includes(extension) && defaultSettings.imageExtensions.includes(extension)) return captureImage(input, output, parsedSize) + + // capture other media with canvas in main window + await captureWithCanvas({ input, output, size: parsedSize, extension: getExtension(input), config }) +} + +let mediaBeingCaptured: number = 0 +const maxAmount = 30 +const refillMargin = maxAmount * 0.6 +async function captureWithCanvas(data: any) { + mediaBeingCaptured++ + toApp(MAIN, { channel: "CAPTURE_CANVAS", data }) + + // generate a max amount at the same time + if (mediaBeingCaptured > maxAmount) await waitUntilValueIsDefined(() => mediaBeingCaptured < refillMargin) + + generationFinished() +} + +export function saveImage(data: any) { + mediaBeingCaptured-- + if (!data.base64) return + + let dataURL = data.base64 + let image = nativeImage.createFromDataURL(dataURL) + saveToDisk(data.path, image, false) +} + +// https://www.electronjs.org/docs/latest/api/native-image +function captureImage(input: string, output: string, size: ResizeOptions) { + let outputImage = nativeImage.createFromPath(input) + outputImage = outputImage.resize(size) + + saveToDisk(output, outputImage) +} + +function getExtension(filePath: string) { + return path.extname(filePath).slice(1).toLowerCase() +} + +function parseSize(sizeStr: string): ResizeOptions { + const size: ResizeOptions = {} + + const sizeRegex = /(\d+|\?)x(\d+|\?)/g + const sizeResult = sizeRegex.exec(sizeStr) + if (sizeResult) { + const sizeValues = sizeResult.map((x) => (x === "?" ? null : Number.parseInt(x))) + + if (sizeValues[1]) size.width = sizeValues[1] || 0 + if (sizeValues[2]) size.height = sizeValues[2] || 0 + + return size + } + + throw new Error("Invalid size string") +} + +///// SAVE ///// + +const jpegQuality = 90 // 0-100 +function saveToDisk(savePath: string, image: NativeImage, nextOnFinished: boolean = true) { + let jpgImage = image.toJPEG(jpegQuality) + fs.writeFile(savePath, jpgImage, () => { + exists.push(savePath) + if (nextOnFinished) generationFinished() + }) +} diff --git a/src/electron/index.ts b/src/electron/index.ts index c9225da6..77701683 100644 --- a/src/electron/index.ts +++ b/src/electron/index.ts @@ -3,7 +3,7 @@ import { BrowserWindow, Menu, Rectangle, app, ipcMain, screen } from "electron" import path from "path" -import { CLOUD, EXPORT, FILE_INFO, MAIN, NDI, OPEN_FILE, OPEN_FOLDER, OUTPUT, READ_FOLDER, RECORDER, SHOW, STARTUP, STORE } from "../types/Channels" +import { CLOUD, EXPORT, MAIN, NDI, OUTPUT, RECORDER, SHOW, STARTUP, STORE } from "../types/Channels" import { BIBLE, IMPORT } from "./../types/Channels" import { cloudConnect } from "./cloud/cloud" import { startBackup } from "./data/backup" @@ -13,17 +13,26 @@ import { receiveNDI } from "./ndi/talk" import { closeAllOutputs, receiveOutput } from "./output/output" import { closeServers } from "./servers" import { stopApiListener } from "./utils/api" -import { checkShowsFolder, dataFolderNames, deleteFile, getDataFolder, getFileInfo, getFolderContent, loadShows, selectFiles, selectFolder, writeFile } from "./utils/files" +import { checkShowsFolder, dataFolderNames, deleteFile, getDataFolder, loadShows, writeFile } from "./utils/files" import { template } from "./utils/menuTemplate" import { stopMidi } from "./utils/midi" -import { catchErrors, loadScripture, loadShow, receiveMain, renameShows, saveRecording, startExport, startImport } from "./utils/responses" +import { catchErrors, loadScripture, loadShow, receiveMain, renameShows, saveRecording, startImport } from "./utils/responses" import { loadingOptions, mainOptions } from "./utils/windowOptions" +import { startExport } from "./data/export" +import { currentlyDeletedShows } from "./cloud/drive" // ----- STARTUP ----- // check if app's in production or not export const isProd: boolean = process.env.NODE_ENV === "production" || !/[\\/]electron/.exec(process.execPath) +// remove "Disabled webSecurity" console warning as it is only disabled in development +if (!isProd) process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true" + +// development settings +export const OUTPUT_CONSOLE: boolean = false +const RECORD_STARTUP_TIME: boolean = false + // get os platform export const isWindows: boolean = process.platform === "win32" export const isMac: boolean = process.platform === "darwin" @@ -38,15 +47,20 @@ console.log("Starting FreeShow...") if (!isProd) console.log("Building app! This may take 20-90 seconds") if (isLinux) console.log("libva error on Linux can be ignored") +// set application menu +setGlobalMenu() + // start when ready +if (RECORD_STARTUP_TIME) console.time("Full startup") app.on("ready", startApp) function startApp() { - createLoading() + if (RECORD_STARTUP_TIME) console.time("Initial") + setTimeout(createLoading) updateDataPath({ load: true }) + if (RECORD_STARTUP_TIME) console.timeEnd("Initial") - setTimeout(createMain, 100) - setTimeout(initialize, 3000) + createMain() } function initialize() { @@ -66,47 +80,19 @@ function initialize() { // get LOADED message from frontend let isLoaded: boolean = false -ipcMain.once("LOADED", () => { +ipcMain.once("LOADED", mainWindowLoaded) +function mainWindowLoaded() { + if (RECORD_STARTUP_TIME) console.timeEnd("Main window content") isLoaded = true + + initialize() + if (config.get("maximized")) mainWindow!.maximize() mainWindow?.show() loadingWindow?.close() -}) -// ----- CUSTOM EXTENSION ----- -// rquires administator access on install - -// Custom .show file - -// var data = null; -// if (isWindows && process.argv.length >= 2) { -// var openFilePath = process.argv[1]; -// data = fs.readFileSync(openFilePath, 'utf-8'); -// } - -// "fileAssociations": [ -// { -// "ext": "show", -// "name": "FreeShow Show", -// "description": "A FreeShow Show File", -// "role": "Editor" -// }, -// { -// "ext": "fsb", -// "name": "FreeShow Bible", -// "description": "A FreeShow Bible File", -// "role": "Editor" -// }, -// { -// "ext": "project", -// "name": "FreeShow Project", -// "description": "A FreeShow Project File", -// "role": "Editor" -// } -// ], -// "nsis": { -// "perMachine": true -// }, + if (RECORD_STARTUP_TIME) console.timeEnd("Full startup") +} // ----- LOADING WINDOW ----- @@ -121,31 +107,44 @@ function createLoading() { export let mainWindow: BrowserWindow | null = null export let dialogClose: boolean = false // is unsaved +const MIN_WINDOW_SIZE = 200 function createMain() { + if (RECORD_STARTUP_TIME) console.time("Main window") let bounds: Rectangle = config.get("bounds") let screenBounds: Rectangle = screen.getPrimaryDisplay().bounds let options: any = { width: !bounds.width || bounds.width === 800 ? screenBounds.width : bounds.width, height: !bounds.height || bounds.height === 600 ? screenBounds.height : bounds.height, - x: !bounds.x ? screenBounds.x : bounds.x, - y: !bounds.y ? screenBounds.y : bounds.y, frame: !isProd || !isWindows, autoHideMenuBar: isProd && isWindows, } + // should be centered to screen if x & y is not set + if (bounds.x) options.x = bounds.x + if (bounds.y) options.y = bounds.y + + // set minimum window size on startup (in case it's tiny) + options.width = Math.max(MIN_WINDOW_SIZE, options.width) + options.height = Math.max(MIN_WINDOW_SIZE, options.height) + // create window mainWindow = new BrowserWindow({ ...mainOptions, ...options }) + // this is to debug any weird positioning + console.log("Main Window Bounds:", mainWindow.getBounds()) + loadWindowContent(mainWindow) setMainListeners() - setGlobalMenu() // open devtools if (!isProd) mainWindow.webContents.openDevTools() + + if (RECORD_STARTUP_TIME) console.timeEnd("Main window") } export function loadWindowContent(window: BrowserWindow, isOutput: boolean = false) { + if (!isOutput && RECORD_STARTUP_TIME) console.time("Main window content") if (!isOutput) console.log("Loading main window content") if (isProd) window.loadFile("public/index.html").catch(error) else window.loadURL("http://localhost:3000").catch(error) @@ -247,7 +246,7 @@ export function maximizeMain() { // https://stackoverflow.com/a/58823019 // quit app when all windows have been closed -// I think this is never called, because of the "will-quit" listener +// this is never called on mac, because of the "will-quit" listener app.on("window-all-closed", () => { app.quit() }) @@ -318,7 +317,7 @@ function save(data: any) { if (data.showsCache) Object.entries(data.showsCache).forEach(saveShow) function saveShow([id, value]: any) { if (!value) return - let p: string = path.resolve(data.path, (value.name || id) + ".show") + let p: string = path.join(data.path, (value.name || id) + ".show") // WIP will overwrite a file with JSON data from another show 0,007% of the time (7 shows get broken when saving 1000 at the same time) writeFile(p, JSON.stringify([id, value]), id) } @@ -327,8 +326,12 @@ function save(data: any) { if (data.deletedShows) data.deletedShows.forEach(deleteShow) function deleteShow({ name, id }: any) { if (!name || data.showsCache[id]) return - let p: string = path.resolve(data.path, (name || id) + ".show") + + let p: string = path.join(data.path, (name || id) + ".show") deleteFile(p) + + // update cloud + currentlyDeletedShows.push(id) } // SAVED @@ -344,16 +347,12 @@ function save(data: any) { // ----- LISTENERS ----- +ipcMain.on(MAIN, receiveMain) +ipcMain.on(OUTPUT, receiveOutput) ipcMain.on(IMPORT, startImport) ipcMain.on(EXPORT, startExport) -ipcMain.on(BIBLE, loadScripture) ipcMain.on(SHOW, loadShow) -ipcMain.on(MAIN, receiveMain) -ipcMain.on(OUTPUT, receiveOutput) -ipcMain.on(READ_FOLDER, getFolderContent) -ipcMain.on(OPEN_FOLDER, selectFolder) -ipcMain.on(OPEN_FILE, selectFiles) -ipcMain.on(FILE_INFO, getFileInfo) +ipcMain.on(BIBLE, loadScripture) ipcMain.on(CLOUD, cloudConnect) ipcMain.on(RECORDER, saveRecording) ipcMain.on(NDI, receiveNDI) @@ -365,6 +364,12 @@ export const toApp = (channel: string, ...args: any[]): void => mainWindow?.webC // set/update global application menu export function setGlobalMenu(strings: any = {}) { + if (isProd && isWindows) { + // set to null as it is not used on Windows + Menu.setApplicationMenu(null) + return + } + const menu: Menu = Menu.buildFromTemplate(template(strings)) Menu.setApplicationMenu(menu) } diff --git a/src/electron/output/capture.ts b/src/electron/output/capture.ts index 2f8a6bb5..dde34517 100644 --- a/src/electron/output/capture.ts +++ b/src/electron/output/capture.ts @@ -23,7 +23,7 @@ export const framerates: any = { } export let customFramerates: any = {} -function getDefaultCapture(window: BrowserWindow, id:string): CaptureOptions { +function getDefaultCapture(window: BrowserWindow, id: string): CaptureOptions { let screen: Display = getWindowScreen(window) const previewFramerate = Math.round(framerates.preview / Object.keys(captures).length) @@ -40,14 +40,14 @@ function getDefaultCapture(window: BrowserWindow, id:string): CaptureOptions { displayFrequency: screen.displayFrequency || 60, options: { server: false, ndi: false }, framerates: defaultFramerates, - id + id, } } // START export let storedFrames: any = {} -export function startCapture(id: string, toggle: any = {}, rate: any = {}) { +export function startCapture(id: string, toggle: any = {}, rate: any = "") { let window = outputWindows[id] let windowIsRemoved = !window || window.isDestroyed() if (windowIsRemoved) { @@ -55,6 +55,13 @@ export function startCapture(id: string, toggle: any = {}, rate: any = {}) { return } + // change preview frame rate based on settings + if (!rate) rate = "auto" + if (rate === "optimized") framerates.preview = 1 // 1 fps + else if (rate === "reduced") framerates.preview = 10 // 10 fps + else if (rate === "full") framerates.preview = 60 // 60 fps + else framerates.preview = 30 // auto // 0.5 fpgs OR 30 fps + if (!captures[id]) captures[id] = getDefaultCapture(window, id) Object.keys(toggle).map((key) => { @@ -65,37 +72,39 @@ export function startCapture(id: string, toggle: any = {}, rate: any = {}) { if (captures[id].subscribed) return - //captures[id].options.ndi = true CaptureTransmitter.startTransmitting(id) //framerates.preview = rate === "full" ? 60 : 30 - if (rate !== "optimized") captures[id].window.webContents.beginFrameSubscription(true, processFrame) + if (rate === "auto" || rate === "full") captures[id].window.webContents.beginFrameSubscription(true, processFrame) // updates approximately every 0.02s captures[id].subscribed = true + if (rate === "full" || (rate !== "optimized" && captures[id].options.ndi)) return + // optimize cpu on low end devices const autoOptimizePercentageCPU = 95 / 10 // % / 10 const captureAmount = 4 * 60 let captureCount = captureAmount - if (rate !== "full" && (rate === "optimized" || !captures[id].options.ndi)) cpuCapture() - + cpuCapture() async function cpuCapture() { if (!captures[id] || captures[id].window.isDestroyed()) return let usage = process.getCPUUsage() - let isOptimizedOrLagging = rate === "optimized" || usage.percentCPUUsage > autoOptimizePercentageCPU || captureCount < captureAmount + let isOptimizedOrLagging = rate !== "auto" || captureCount < captureAmount || usage.percentCPUUsage > autoOptimizePercentageCPU if (isOptimizedOrLagging) { if (captureCount > captureAmount) captureCount = 0 // limit frames if (captures[id].window.webContents.isBeingCaptured()) captures[id].window.webContents.endFrameSubscription() - //let image = await captures[id].window.webContents.capturePage() - //CaptureTransmitter.sendFrames(captures[id], image, { previewFrame: true, serverFrame: true, ndiFrame: true }) - // capture for 60 seconds then get cpu again + // manually capture to reduce lag + let image = await captures[id].window.webContents.capturePage() + processFrame(image) + + // capture for 60 seconds then get cpu again (if rate is "auto") captureCount++ - setTimeout(cpuCapture, rate === "optimized" ? 2000 : 250) + setTimeout(cpuCapture, rate === "optimized" ? 1000 : 100) } else { captureCount = captureAmount if (!captures[id].window.webContents.isBeingCaptured()) captures[id].window.webContents.beginFrameSubscription(true, processFrame) @@ -105,7 +114,6 @@ export function startCapture(id: string, toggle: any = {}, rate: any = {}) { function processFrame(image: NativeImage) { storedFrames[id] = image } - } export function updateFramerate(id: string) { @@ -119,7 +127,6 @@ export function updateFramerate(id: string) { captures[id].framerates.ndi = parseInt(ndiFramerate) CaptureTransmitter.startChannel(id, "ndi") } - } } @@ -140,7 +147,9 @@ export function resizeImage(image: NativeImage, initialSize: Size, newSize: Size } export let previewSize: Size = { width: 320, height: 180 } -export function updatePreviewResolution(data: any) { previewSize = data.size } +export function updatePreviewResolution(data: any) { + previewSize = data.size +} // STOP @@ -169,7 +178,7 @@ export function stopCapture(id: string) { function endSubscription() { if (!captures[id].subscribed) return - + captures[id].window.webContents.endFrameSubscription() captures[id].subscribed = false } diff --git a/src/electron/output/output.ts b/src/electron/output/output.ts index c2ed3530..87474769 100644 --- a/src/electron/output/output.ts +++ b/src/electron/output/output.ts @@ -1,13 +1,13 @@ import { BrowserWindow, Rectangle, screen } from "electron" -import { isMac, loadWindowContent, mainWindow, toApp } from ".." +import { OUTPUT_CONSOLE, isMac, loadWindowContent, mainWindow, toApp } from ".." import { MAIN, OUTPUT } from "../../types/Channels" import { Output } from "../../types/Output" import { Message } from "../../types/Socket" +import { NdiSender } from "../ndi/NdiSender" import { setDataNDI } from "../ndi/talk" import { outputOptions, screenIdentifyOptions } from "../utils/windowOptions" -import { startCapture, stopCapture, updatePreviewResolution } from "./capture" -import { NdiSender } from "../ndi/NdiSender" import { CaptureTransmitter } from "./CaptureTransmitter" +import { startCapture, stopCapture, updatePreviewResolution } from "./capture" export let outputWindows: { [key: string]: BrowserWindow } = {} @@ -40,6 +40,7 @@ function createOutputWindow(options: any, id: string, name: string) { options.resizable = true } + if (OUTPUT_CONSOLE) options.webPreferences.devTools = true let window: BrowserWindow | null = new BrowserWindow(options) // only win & linux @@ -49,14 +50,16 @@ function createOutputWindow(options: any, id: string, name: string) { window.setSkipTaskbar(options.skipTaskbar) // hide from taskbar if (isMac) window.minimize() // hide on mac - if (options.alwaysOnTop) window.setAlwaysOnTop(true, "pop-up-menu", 1) + window.once("show", () => { + if (options.alwaysOnTop) window?.setAlwaysOnTop(true, "pop-up-menu", 1) + }) // window.setVisibleOnAllWorkspaces(true) loadWindowContent(window, true) setWindowListeners(window, { id, name }) // open devtools - // if (!isProd) window.webContents.openDevTools() + if (OUTPUT_CONSOLE) window.webContents.openDevTools({ mode: "detach" }) return window } @@ -244,7 +247,7 @@ const setValues: any = { window.setBackgroundColor(value ? "#00000000" : "#000000") }, alwaysOnTop: (value: boolean, window: BrowserWindow) => { - window.setAlwaysOnTop(value) + window.setAlwaysOnTop(value, "pop-up-menu", 1) window.setResizable(!value) window.setSkipTaskbar(value) }, @@ -268,12 +271,28 @@ function moveToFront(id: string) { window.moveTop() } +function alignWithScreens() { + Object.keys(outputWindows).forEach((outputId) => { + let output = outputWindows[outputId] + + let wBounds = output.getBounds() + let centerLeft = wBounds.x + wBounds.width / 2 + let centerTop = wBounds.y + wBounds.height / 2 + + let point = { x: centerLeft, y: centerTop } + let closestScreen = screen.getDisplayNearestPoint(point) + + output.setBounds(closestScreen.bounds) + }) +} + // RESPONSES const outputResponses: any = { CREATE: (data: any) => createOutput(data), REMOVE: (data: any) => removeOutput(data.id), DISPLAY: (data: any) => displayOutput(data), + ALIGN_WITH_SCREEN: () => alignWithScreens(), MOVE: (data: any) => (moveEnabled = data.enabled), diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 28c7c8bd..889573c1 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -1,21 +1,23 @@ // ----- FreeShow ----- // Expose protected methods that allow the renderer process to use the ipcRenderer without exposing the entire object -import type { ValidChannels } from "../types/Channels" import { IpcRendererEvent, contextBridge, ipcRenderer } from "electron" +import type { ValidChannels } from "../types/Channels" // const maxInterval: number = 500 // const useTimeout: ValidChannels[] = ["STAGE", "REMOTE", "CONTROLLER", "OUTPUT_STREAM"] // let lastChannel: string = "" -const debug: boolean = true +// wait to log messages until after intial load is done +let appLoaded: boolean = false +const LOG_MESSAGES: boolean = process.env.NODE_ENV !== "production" const filteredChannels: any[] = ["AUDIO_MAIN", "VIZUALISER_DATA", "STREAM", "PREVIEW", "REQUEST_STREAM", "MAIN_TIME"] let storedReceivers: any = {} contextBridge.exposeInMainWorld("api", { send: (channel: ValidChannels, data: any) => { - if (debug && !filteredChannels.includes(data?.channel)) console.log("TO ELECTRON [" + channel + "]: ", data) + if (LOG_MESSAGES && appLoaded && !filteredChannels.includes(data?.channel)) console.log("TO ELECTRON [" + channel + "]: ", data) // if (useTimeout.includes(channel) && data.channel === lastChannel && data.id) return ipcRenderer.send(channel, data) @@ -25,7 +27,8 @@ contextBridge.exposeInMainWorld("api", { }, receive: (channel: ValidChannels, func: any, id: string = "") => { const receiver = (_e: IpcRendererEvent, ...args: any[]) => { - if (debug && !filteredChannels.includes(args[0]?.channel)) console.log("TO CLIENT [" + channel + "]: ", ...args) + if (!appLoaded && channel === "STORE" && args[0]?.channel === "SHOWS") setTimeout(() => (appLoaded = true), 3000) + if (LOG_MESSAGES && appLoaded && !filteredChannels.includes(args[0]?.channel)) console.log("TO CLIENT [" + channel + "]: ", ...args) func(...args) } diff --git a/src/electron/utils/LyricSearch.ts b/src/electron/utils/LyricSearch.ts new file mode 100644 index 00000000..5c12c3db --- /dev/null +++ b/src/electron/utils/LyricSearch.ts @@ -0,0 +1,153 @@ +import axios from "axios" + +export type LyricSearchResult = { + source: string, + key: string, + artist: string, + title: string + originalQuery?: string +} + +export class LyricSearch { + + + + static search = async (artist:string, title: string) => { + const results = await Promise.all([ + LyricSearch.searchGenius(artist, title), + LyricSearch.searchHymnary(title) + ]) + return results.flat() + } + + static get(song:LyricSearchResult) { + if (song.source === "Genius") return LyricSearch.getGenius(song) + else if (song.source === "Hymnary") return LyricSearch.getHymnary(song) + return Promise.resolve("") + } + + + //GENIUS + private static getGeniusClient = () => { + const Genius = require("genius-lyrics") + return new Genius.Client() + } + + private static searchGenius = async (artist:string, title: string) => { + try { + const client = this.getGeniusClient() + const songs = await client.songs.search(title + artist) + if (songs.length>3) songs.splice(3, songs.length-3) + return songs.map((s:any) => LyricSearch.convertGenuisToResult(s, title + artist)); + } catch (ex) { + console.log(ex); + return [] + } + } + + //Would greatly prefer to just load via url or id, but the api fails often with these methods (malformed json) + private static getGenius = async (song:LyricSearchResult) => { + const client = this.getGeniusClient() + const songs = await client.songs.search(song.originalQuery || "") + let result = ""; + for (let i = 0; i < songs.length; i++) { + if (songs[i].id.toString() === song.key) { + result = await songs[i].lyrics() + break + } + } + return result + } + + private static convertGenuisToResult = (geniusResult:any, originalQuery:string) => { + return { + source: "Genius", + key: geniusResult.id.toString(), + artist: geniusResult.artist.name, + title: geniusResult.title, + originalQuery: originalQuery + } as LyricSearchResult + } + + //HYMNARY + private static searchHymnary = async (title: string) => { + try { + const url = `https://hymnary.org/search?qu=%20tuneTitle%3A${encodeURIComponent(title)}%20media%3Atext%20in%3Atexts&export=csv` + const response = await axios.get(url) + const csv = await response.data + const songs = LyricSearch.CSVToArray(csv, ",") + if (songs.length>0) songs.splice(0, 1) + for (let i=songs.length-1; i>=0; i--) if (songs[i].length<7) songs.splice(i, 1) + if (songs.length>3) songs.splice(3, songs.length-3) + return songs.map((s:any) => LyricSearch.convertHymnaryToResult(s, title)); + } catch (ex) { + console.log(ex); + return [] + } + } + + private static getHymnary = async (song:LyricSearchResult) => { + const url = `https://hymnary.org/text/${song.key}` + const response = await axios.get(url) + const html = await response.data + const regex = /
(.*?)<\/div>/sg + const match = regex.exec(html) + + let result = "" + if (match) { + result = match[0] + result = result.replaceAll("

", "\n\n") + result = result.replace(/<[^>]*>?/gm, ''); + + const lines = result.split("\n") + const newLines:any[] = [] + lines.forEach((line, idx) => { + if (idx { + return { + source: "Hymnary", + key: hymnaryResult[4], + artist: hymnaryResult[6], + title: hymnaryResult[0], + originalQuery: originalQuery + } as LyricSearchResult + } + + // ref: http://stackoverflow.com/a/1293163/2343 + // This will parse a delimited string into an array of + // arrays. The default delimiter is the comma, but this + // can be overriden in the second argument. + static CSVToArray( strData:string, strDelimiter:string ){ + strDelimiter = (strDelimiter || ","); + + var objPattern = new RegExp(( + "(\\" + strDelimiter + "|\\r?\\n|\\r|^)" + + "(?:\"([^\"]*(?:\"\"[^\"]*)*)\"|" + + "([^\"\\" + strDelimiter + "\\r\\n]*))" + ), "gi"); + + var arrData:any[] = [[]]; + var arrMatches = null; + while (arrMatches = objPattern.exec( strData )){ + var strMatchedDelimiter = arrMatches[ 1 ]; + if (strMatchedDelimiter.length && strMatchedDelimiter !== strDelimiter) { arrData.push( [] ); } + var strMatchedValue; + if (arrMatches[ 2 ]) strMatchedValue = arrMatches[ 2 ].replace(new RegExp( "\"\"", "g" ), "\""); + else strMatchedValue = arrMatches[ 3 ]; + arrData[ arrData.length - 1 ].push( strMatchedValue ); + } + return( arrData ); + } + +} \ No newline at end of file diff --git a/src/electron/utils/api.ts b/src/electron/utils/api.ts index 8fae728b..382788e4 100644 --- a/src/electron/utils/api.ts +++ b/src/electron/utils/api.ts @@ -9,6 +9,11 @@ const app = express() let servers: any = {} const DEFAULT_PORTS = { WebSocket: 5505, REST: 5506 } +export function startWebSocketAndRest(port: number | undefined) { + startRestListener(port ? port + 1 : 0) + startWebSocket(port) +} + // WEBSOCKET export function startWebSocket(PORT: number | undefined) { diff --git a/src/electron/utils/files.ts b/src/electron/utils/files.ts index 48a19e99..a824b8f6 100644 --- a/src/electron/utils/files.ts +++ b/src/electron/utils/files.ts @@ -9,9 +9,11 @@ import path, { join, parse } from "path" import { uid } from "uid" import { FILE_INFO, MAIN, OPEN_FOLDER, READ_FOLDER, SHOW, STORE } from "../../types/Channels" import { stores } from "../data/store" +import { createThumbnail } from "../data/thumbnails" import { OPEN_FILE } from "./../../types/Channels" import { mainWindow, toApp } from "./../index" import { getAllShows, trimShow } from "./responses" +import { defaultSettings } from "../data/defaults" function actionComplete(err: Error | null, actionFailedMessage: string) { if (err) console.error(actionFailedMessage + ":", err) @@ -71,7 +73,7 @@ export function renameFile(p: string, oldName: string, newName: string) { export function getFileStats(p: string, disableLog: boolean = false) { try { const stat: Stats = fs.statSync(p) - return { path: p, stat, extension: path.extname(p).substring(1), folder: stat.isDirectory() } + return { path: p, stat, extension: path.extname(p).substring(1).toLowerCase(), folder: stat.isDirectory() } } catch (err) { if (!disableLog) actionComplete(err, "Error when getting file stats") return null @@ -110,7 +112,7 @@ const appFolderName = "FreeShow" export function getDocumentsFolder(p: any = null, folderName: string = "Shows"): string { let folderPath = [app.getPath("documents"), appFolderName] if (folderName) folderPath.push(folderName) - if (!p) p = path.resolve(...folderPath) + if (!p) p = path.join(...folderPath) if (!doesPathExist(p)) p = fs.mkdirSync(p, { recursive: true }) return p @@ -184,8 +186,19 @@ export function getPaths(): any { return paths } +const tempPaths = ["temp"] +export function getTempPaths() { + let paths: any = {} + tempPaths.forEach((pathId: any) => { + paths[pathId] = app.getPath(pathId) + }) + + return paths +} + // READ_FOLDER -export function getFolderContent(_e: any, data: any) { +const MEDIA_EXTENSIONS = [...defaultSettings.imageExtensions, ...defaultSettings.videoExtensions] +export function getFolderContent(data: any) { let folderPath: string = data.path let fileList: string[] = readFolder(folderPath) @@ -198,7 +211,12 @@ export function getFolderContent(_e: any, data: any) { for (const name of fileList) { let p: string = path.join(folderPath, name) let stats: any = getFileStats(p) - if (stats) files.push({ ...stats, name }) + if (stats) files.push({ ...stats, name, thumbnailPath: isMedia() ? createThumbnail(p) : "" }) + + function isMedia() { + if (stats.folder) return false + return MEDIA_EXTENSIONS.includes(stats.extension) + } } if (!files.length) { @@ -284,7 +302,7 @@ function similarity(str1: string, str2: string) { } // OPEN_FOLDER -export function selectFolder(e: any, msg: { channel: string; title: string | undefined; path: string | undefined }) { +export function selectFolder(msg: { channel: string; title: string | undefined; path: string | undefined }, e: any) { let folder: any = selectFolderDialog(msg.title, msg.path) if (!folder) return @@ -306,7 +324,7 @@ export function selectFolder(e: any, msg: { channel: string; title: string | und } // OPEN_FILE -export function selectFiles(e: any, msg: { id: string; channel: string; title?: string; filter: any; multiple: boolean; read?: boolean }) { +export function selectFiles(msg: { id: string; channel: string; title?: string; filter: any; multiple: boolean; read?: boolean }, e: any) { let files: any = selectFilesDialog(msg.title, msg.filter, msg.multiple === undefined ? true : msg.multiple) if (!files) return @@ -320,7 +338,7 @@ export function selectFiles(e: any, msg: { id: string; channel: string; title?: } // FILE_INFO -export function getFileInfo(e: any, filePath: string) { +export function getFileInfo(filePath: string, e: any) { let stats: any = getFileStats(filePath) if (stats) e.reply(FILE_INFO, stats) } diff --git a/src/electron/utils/helpers.ts b/src/electron/utils/helpers.ts new file mode 100644 index 00000000..a7eafabf --- /dev/null +++ b/src/electron/utils/helpers.ts @@ -0,0 +1,34 @@ +// async wait (instead of timeouts) +export function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(() => { + resolve("ended") + }, Number(ms)) + }) +} + +// wait until input value is true +export async function waitUntilValueIsDefined(value: Function, intervalTime: number = 50, timeoutValue: number = 5000) { + return new Promise(async (resolve) => { + let currentValue = await value() + if (currentValue) resolve(currentValue) + + const timeout = setTimeout(() => { + exit() + resolve(null) + }, timeoutValue) + + const interval = setInterval(async () => { + currentValue = await value() + if (!currentValue) return + + exit() + resolve(currentValue) + }, intervalTime) + + function exit() { + clearTimeout(timeout) + clearInterval(interval) + } + }) +} diff --git a/src/electron/utils/midi.ts b/src/electron/utils/midi.ts index e8453d4e..89ae44f1 100644 --- a/src/electron/utils/midi.ts +++ b/src/electron/utils/midi.ts @@ -30,7 +30,6 @@ export function getMidiInputs() { let openedOutputPorts: any = {} export async function sendMidi(data: any) { let port: any = null - // console.log("OUTPUT", data.output) if (!data.type) data.type = "noteon" if (!data.values) data.values = { note: 0, velocity: 0, channel: 1 } @@ -43,6 +42,7 @@ export async function sendMidi(data: any) { port = openedOutputPorts[data.output] if (!port) return + console.info("SENDING MIDI SIGNAL:", data) if (data.type === "noteon") { // this might be rendered as note off by some programs if velocity is 0, but that should be fine await port.noteOn(data.values.channel, data.values.note, data.values.velocity) @@ -65,12 +65,10 @@ export async function receiveMidi(data: any) { if (!data.input) return if (openedPorts[data.id]) return - console.log(data) - try { // connect to the input and listen for notes! let port = await JZZ().openMidiIn(data.input).or("Error opening MIDI listener: Device not found or not supported!") - // console.log("MIDI-In:", port.name()) + console.info("LISTENING FOR MIDI SIGNAL:", data) if (port.name()) openedPorts[data.id] = port diff --git a/src/electron/utils/responses.ts b/src/electron/utils/responses.ts index cc208bcd..25500b6f 100644 --- a/src/electron/utils/responses.ts +++ b/src/electron/utils/responses.ts @@ -9,19 +9,26 @@ import path from "path" import { closeMain, isLinux, isProd, mainWindow, maximizeMain, setGlobalMenu, toApp } from ".." import { BIBLE, MAIN, SHOW } from "../../types/Channels" import { Show } from "../../types/Show" -import { closeServers, startServers } from "../servers" -import { Message } from "./../../types/Socket" import { restoreFiles } from "../data/backup" import { downloadMedia } from "../data/downloadMedia" -import { createPDFWindow, exportProject, exportTXT } from "../data/export" +import { importShow } from "../data/import" +import { error_log } from "../data/store" +import { getThumbnail, saveImage } from "../data/thumbnails" +import { outputWindows } from "../output/output" +import { closeServers, startServers } from "../servers" +import { Message } from "./../../types/Socket" +import { startWebSocketAndRest, stopApiListener } from "./api" import { checkShowsFolder, dataFolderNames, deleteFile, getDataFolder, getDocumentsFolder, + getFileInfo, + getFolderContent, getPaths, getSimularPaths, + getTempPaths, loadFile, locateMediaFile, openSystemFolder, @@ -30,15 +37,13 @@ import { readFile, readFolder, renameFile, + selectFiles, selectFilesDialog, - selectFolderDialog, + selectFolder, writeFile, } from "./files" -import { importShow } from "../data/import" +import { LyricSearch } from "./LyricSearch" import { closeMidiInPorts, getMidiInputs, getMidiOutputs, receiveMidi, sendMidi } from "./midi" -import { outputWindows } from "../output/output" -import { error_log } from "../data/store" -import { startRestListener, startWebSocket, stopApiListener } from "./api" import checkForUpdates from "./updater" // IMPORT @@ -52,37 +57,15 @@ export function startImport(_e: any, msg: Message) { importShow(msg.channel, files || null, msg.data.path) } -// EXPORT -export function startExport(_e: any, msg: Message) { - if (msg.channel !== "GENERATE") return - - let dataPath: string = msg.data.path - - if (!dataPath) { - dataPath = selectFolderDialog() - if (!dataPath) return - - toApp(MAIN, { channel: "DATA_PATH", data: dataPath }) - } - - msg.data.path = getDataFolder(dataPath, dataFolderNames.exports) - - // WIP open in system when completed... - - if (msg.data.type === "pdf") createPDFWindow(msg.data) - else if (msg.data.type === "txt") exportTXT(msg.data) - else if (msg.data.type === "project") exportProject(msg.data) -} - // BIBLE export function loadScripture(e: any, msg: Message) { let bibleFolder: string = getDataFolder(msg.path || "", dataFolderNames.scriptures) - let p: string = path.resolve(bibleFolder, msg.name + ".fsb") + let p: string = path.join(bibleFolder, msg.name + ".fsb") let bible: any = loadFile(p, msg.id) // pre v0.5.6 - if (bible.error) p = path.resolve(app.getPath("documents"), "Bibles", msg.name + ".fsb") + if (bible.error) p = path.join(app.getPath("documents"), "Bibles", msg.name + ".fsb") bible = loadFile(p, msg.id) if (msg.data) bible.data = msg.data @@ -92,7 +75,7 @@ export function loadScripture(e: any, msg: Message) { // SHOW export function loadShow(e: any, msg: Message) { let p: string = checkShowsFolder(msg.path || "") - p = path.resolve(p, (msg.name || msg.id) + ".show") + p = path.join(p, (msg.name || msg.id) + ".show") let show: any = loadFile(p, msg.id) e.reply(SHOW, show) @@ -100,81 +83,80 @@ export function loadShow(e: any, msg: Message) { // MAIN const mainResponses: any = { + // DATA LOG: (data: string): void => console.log(data), VERSION: (): string => app.getVersion(), IS_DEV: (): boolean => !isProd, GET_OS: (): any => ({ platform: os.platform(), name: os.hostname(), arch: os.arch() }), DEVICE_ID: (): string => machineIdSync(), + IP: (): any => os.networkInterfaces(), + // APP + CLOSE: (): void => closeMain(), + MAXIMIZE: (): void => maximizeMain(), + MAXIMIZED: (): boolean => !!mainWindow?.isMaximized(), + MINIMIZE: (): void => mainWindow?.minimize(), + FULLSCREEN: (): void => mainWindow?.setFullScreen(!mainWindow?.isFullScreen()), + // MAIN AUTO_UPDATE: (): void => checkForUpdates(), GET_SYSTEM_FONTS: (): void => loadFonts(), URL: (data: string): void => openURL(data), - START: (data: any): void => startServers(data), - STOP: (): void => closeServers(), - IP: (): any => os.networkInterfaces(), LANGUAGE: (data: any): void => setGlobalMenu(data.strings), + GET_PATHS: (): any => getPaths(), + GET_TEMP_PATHS: (): any => getTempPaths(), SHOWS_PATH: (): string => getDocumentsFolder(), DATA_PATH: (): string => getDocumentsFolder(null, ""), - DISPLAY: (): boolean => false, - GET_MIDI_OUTPUTS: (): string[] => getMidiOutputs(), - GET_MIDI_INPUTS: (): string[] => getMidiInputs(), + LOG_ERROR: (data: any) => logError(data), + OPEN_LOG: () => openSystemFolder(error_log.path), + // SHOWS + DELETE_SHOWS: (data: any) => deleteShowsNotIndexed(data), + REFRESH_SHOWS: (data: any) => refreshAllShows(data), + FULL_SHOWS_LIST: (data: any) => getAllShows(data), + // OUTPUT GET_SCREENS: (): void => getScreens(), GET_WINDOWS: (): void => getScreens("window"), GET_DISPLAYS: (): Display[] => screen.getAllDisplays(), - GET_PATHS: (): any => getPaths(), OUTPUT: (_: any, e: any): "true" | "false" => (e.sender.id === mainWindow?.webContents.id ? "false" : "true"), - CLOSE: (): void => closeMain(), - MAXIMIZE: (): void => maximizeMain(), - MAXIMIZED: (): boolean => !!mainWindow?.isMaximized(), - MINIMIZE: (): void => mainWindow?.minimize(), - FULLSCREEN: (): void => mainWindow?.setFullScreen(!mainWindow?.isFullScreen()), - SEARCH_LYRICS: (data: any): void => { - searchLyrics(data) - }, + // MEDIA + GET_THUMBNAIL: (data: any): any => getThumbnail(data), + SAVE_IMAGE: (data: any): any => saveImage(data), + READ_EXIF: (data: any, e: any) => readExifData(data, e), + DOWNLOAD_MEDIA: (data: any) => downloadMedia(data), + MEDIA_BASE64: (data: any) => storeMedia(data), + ACCESS_CAMERA_PERMISSION: () => getPermission("camera"), + ACCESS_MICROPHONE_PERMISSION: () => getPermission("microphone"), + ACCESS_SCREEN_PERMISSION: () => getPermission("screen"), + // SERVERS + START: (data: any): void => startServers(data), + STOP: (): void => closeServers(), + // WebSocket / REST + WEBSOCKET_START: (port: number | undefined) => startWebSocketAndRest(port), + WEBSOCKET_STOP: () => stopApiListener(), + // MIDI + GET_MIDI_OUTPUTS: (): string[] => getMidiOutputs(), + GET_MIDI_INPUTS: (): string[] => getMidiInputs(), SEND_MIDI: (data: any): void => { sendMidi(data) }, RECEIVE_MIDI: (data: any): void => { receiveMidi(data) }, - CLOSE_MIDI: (data: any): void => { - closeMidiInPorts(data.id) + CLOSE_MIDI: (data: any): void => closeMidiInPorts(data.id), + // LYRICS + GET_LYRICS: (data: any): void => { + getLyrics(data) }, - DELETE_SHOWS: (data: any) => deleteShowsNotIndexed(data), - REFRESH_SHOWS: (data: any) => refreshAllShows(data), - FULL_SHOWS_LIST: (data: any) => getAllShows(data), - READ_EXIF: (data: any, e: any) => readExifData(data, e), - ACCESS_CAMERA_PERMISSION: () => { - if (process.platform !== "darwin") return - systemPreferences.askForMediaAccess("camera") - }, - ACCESS_MICROPHONE_PERMISSION: () => { - if (process.platform !== "darwin") return - systemPreferences.askForMediaAccess("microphone") - }, - ACCESS_SCREEN_PERMISSION: () => { - if (process.platform !== "darwin") return - systemPreferences.getMediaAccessStatus("screen") + SEARCH_LYRICS: (data: any): void => { + searchLyrics(data) }, + // FILES RESTORE: (data: any) => restoreFiles(data), SYSTEM_OPEN: (data: any) => openSystemFolder(data), LOCATE_MEDIA_FILE: (data: any) => locateMediaFile(data), - DOWNLOAD_MEDIA: (data: any) => downloadMedia(data), - LOG_ERROR: (data: any) => logError(data), - OPEN_LOG: () => openSystemFolder(error_log.path), - MEDIA_BASE64: (data: any) => storeMedia(data), GET_SIMULAR: (data: any) => getSimularPaths(data), - // WebSocket / REST (Combined) - WEBSOCKET_START: (port: number | undefined) => { - startRestListener(port ? port + 1 : 0) - startWebSocket(port) - }, - WEBSOCKET_STOP: () => stopApiListener(), - // // WebSocket (Companion) - // WEBSOCKET_START: (port: number | undefined) => startWebSocket(port), - // WEBSOCKET_STOP: () => stopApiListener("WebSocket"), - // // REST - // REST_START: (port: number | undefined) => startRestListener(port), - // REST_STOP: () => stopApiListener("REST"), + FILE_INFO: (data: any, e: any) => getFileInfo(data, e), + READ_FOLDER: (data: any) => getFolderContent(data), + OPEN_FOLDER: (data: any, e: any) => selectFolder(data, e), + OPEN_FILE: (data: any, e: any) => selectFiles(data, e), } export function receiveMain(e: any, msg: Message) { @@ -277,13 +259,23 @@ function loadFonts() { // SEARCH_LYRICS async function searchLyrics({ artist, title }: any) { - const Genius = require("genius-lyrics") - const Client = new Genius.Client() + const songs = await LyricSearch.search(artist, title) + toApp("MAIN", { channel: "SEARCH_LYRICS", data: songs }) +} + +// GET_LYRICS +async function getLyrics({ song }: any) { + const lyrics = await LyricSearch.get(song) + console.log("****LYRICS", lyrics) + toApp("MAIN", { channel: "GET_LYRICS", data: { lyrics, source: song.source } }) +} - const songs = await Client.songs.search(title + artist) - const lyrics = songs[0] ? await songs[0].lyrics() : "" +// GET DEVICE MEDIA PERMISSION +function getPermission(id: "camera" | "microphone" | "screen") { + if (process.platform !== "darwin") return - toApp("MAIN", { channel: "SEARCH_LYRICS", data: { lyrics } }) + if (id === "screen") systemPreferences.getMediaAccessStatus(id) + else systemPreferences.askForMediaAccess(id) } // GET_SCREENS | GET_WINDOWS @@ -312,7 +304,7 @@ function getScreens(type: "window" | "screen" = "screen") { // RECORDER export function saveRecording(_: any, msg: any) { let folder: string = getDataFolder(msg.path || "", dataFolderNames.recordings) - let p: string = path.resolve(folder, msg.name) + let p: string = path.join(folder, msg.name) const buffer = Buffer.from(msg.blob) writeFile(p, buffer) @@ -321,6 +313,8 @@ export function saveRecording(_: any, msg: any) { // ERROR LOGGER const maxLogLength = 250 export function logError(log: any, electron: boolean = false) { + if (!isProd) return + let storedLog: any = error_log.store let key = electron ? "main" : "renderer" diff --git a/src/electron/utils/windowOptions.ts b/src/electron/utils/windowOptions.ts index 77d664de..ca0fba16 100644 --- a/src/electron/utils/windowOptions.ts +++ b/src/electron/utils/windowOptions.ts @@ -57,7 +57,6 @@ export const outputOptions: any = { // roundedCorners: false, // disable rounded corners on mac webPreferences: { preload: join(__dirname, "..", "preload"), - devTools: !isProd, webSecurity: isProd, nodeIntegration: !isProd, contextIsolation: true, @@ -95,3 +94,14 @@ export const exportOptions: any = { autoplayPolicy: "no-user-gesture-required", }, } + +// export const captureOptions: any = { +// show: false, +// resizable: false, +// frame: false, +// skipTaskbar: true, +// webPreferences: { +// backgroundThrottling: false, +// autoplayPolicy: "no-user-gesture-required", +// }, +// } diff --git a/src/frontend/MainLayout.svelte b/src/frontend/MainLayout.svelte index 73fcca2c..7a47fd03 100644 --- a/src/frontend/MainLayout.svelte +++ b/src/frontend/MainLayout.svelte @@ -88,17 +88,20 @@
diff --git a/src/frontend/components/actions/MidiValues.svelte b/src/frontend/components/actions/MidiValues.svelte index b4a221ba..26daec41 100644 --- a/src/frontend/components/actions/MidiValues.svelte +++ b/src/frontend/components/actions/MidiValues.svelte @@ -46,6 +46,14 @@ send(MAIN, ["GET_MIDI_OUTPUTS"]) } + function setInitialData() { + // set initial data + console.log(midi) + if (midi.values?.note) return + + setTimeout(() => setValues("note", 0), 50) + } + let id = uid() receive( MAIN, @@ -54,11 +62,15 @@ if (!msg.length) return outputs = msg.map((a) => ({ name: a })) if (!midi.output) midi.output = msg[0] + + setInitialData() }, GET_MIDI_INPUTS: (msg) => { if (!msg.length) return inputs = msg.map((a) => ({ name: a })) if (!midi.input) midi.input = msg[0] + + setInitialData() }, RECEIVE_MIDI: (msg) => { if (!autoValues) return diff --git a/src/frontend/components/actions/actionData.ts b/src/frontend/components/actions/actionData.ts index 069b70e6..a79de317 100644 --- a/src/frontend/components/actions/actionData.ts +++ b/src/frontend/components/actions/actionData.ts @@ -39,6 +39,8 @@ export const actionData = { change_volume: { name: "actions.change_volume", icon: "volume", input: "volume" }, start_audio_stream: { slideId: "audioStream", name: "actions.start_audio_stream", icon: "audio_stream", input: "id" }, start_playlist: { name: "actions.start_playlist", icon: "playlist", input: "id" }, + playlist_next: { name: "actions.playlist_next", icon: "playlist" }, + start_metronome: { name: "actions.start_metronome", icon: "metronome", input: "metronome" }, // TIMERS start_slide_timers: { slideId: "startTimer", name: "actions.start_slide_timers", icon: "timer" }, @@ -54,4 +56,5 @@ export const actionData = { start_trigger: { slideId: "trigger", name: "actions.start_trigger", icon: "trigger", input: "id" }, send_midi: { slideId: "sendMidi", name: "actions.send_midi", icon: "music", input: "midi" }, run_action: { name: "actions.run_action", icon: "actions", input: "id" }, + toggle_action: { name: "actions.toggle_action", icon: "actions", input: "toggle_action" }, } diff --git a/src/frontend/components/actions/actions.ts b/src/frontend/components/actions/actions.ts index 4539c60f..ebc57c5b 100644 --- a/src/frontend/components/actions/actions.ts +++ b/src/frontend/components/actions/actions.ts @@ -1,10 +1,10 @@ import { get } from "svelte/store" import { uid } from "uid" -import { midiIn } from "../../stores" +import { audioPlaylists, audioStreams, midiIn, shows, stageShows, styles, triggers } from "../../stores" import { clone } from "../helpers/array" import { history } from "../helpers/history" import { _show } from "../helpers/shows" -import { API_ACTIONS } from "./api" +import { API_ACTIONS, API_toggle } from "./api" import { convertOldMidiToNewAction } from "./midi" export function runActionId(id: string) { @@ -12,7 +12,7 @@ export function runActionId(id: string) { } export function runAction(action, { midiIndex = -1, slideIndex = -1 } = {}) { - if (!action) return + if (!action || action.enabled === false) return action = convertOldMidiToNewAction(action) let triggers = action.triggers || [] @@ -31,7 +31,7 @@ export function runAction(action, { midiIndex = -1, slideIndex = -1 } = {}) { if (midiIndex > -1) triggerData = { ...triggerData, index: midiIndex } if (actionId === "start_slide_timers" && slideIndex > -1) { - let layoutRef = _show("active").layouts().ref()[0] + let layoutRef = _show("active").layouts("active").ref()[0] if (layoutRef) { let overlayIds = layoutRef[slideIndex].data?.overlays triggerData = { overlayIds } @@ -42,10 +42,35 @@ export function runAction(action, { midiIndex = -1, slideIndex = -1 } = {}) { } } +export function toggleAction(data: API_toggle) { + midiIn.update((a) => { + let previousValue = a[data.id].enabled ?? true + a[data.id].enabled = data.value ?? !previousValue + + return a + }) +} + export function checkStartupActions() { + // WIP only for v1.1.7 (can be removed) + midiIn.update((a) => { + Object.keys(a).forEach((actionId) => { + let action: any = a[actionId] + if (action.startupEnabled && !action.customActivation) { + delete action.startupEnabled + action.customActivation = "startup" + } + }) + return a + }) + + customActionActivation("startup") +} + +export function customActionActivation(id: string) { Object.keys(get(midiIn)).forEach((actionId) => { - let action = get(midiIn)[actionId] - if (action.startupEnabled) runAction(action) + let action: any = get(midiIn)[actionId] + if (action.customActivation === id) runAction(action) }) } @@ -67,3 +92,28 @@ export function addSlideAction(slideIndex: number, actionId: string, actionValue history({ id: "SHOW_LAYOUT", newData: { key: "actions", data: actions, indexes: [slideIndex] } }) } + +// extra names + +const namedObjects = { + run_action: () => get(midiIn), + start_show: () => get(shows), + start_trigger: () => get(triggers), + start_audio_stream: () => get(audioStreams), + start_playlist: () => get(audioPlaylists), + id_select_stage_layout: () => get(stageShows), +} +export function getActionName(actionId: string, actionValue: any) { + if (actionId === "change_output_style") { + return get(styles)[actionValue.outputStyle]?.name + } + + if (actionId === "start_metronome") { + let beats = (actionValue.beats || 4) === 4 ? "" : " | " + actionValue.beats + return (actionValue.tempo || 120) + beats + } + + if (!namedObjects[actionId]) return + + return namedObjects[actionId]()[actionValue.id]?.name +} diff --git a/src/frontend/components/actions/api.ts b/src/frontend/components/actions/api.ts index bb156b41..f392c014 100644 --- a/src/frontend/components/actions/api.ts +++ b/src/frontend/components/actions/api.ts @@ -1,7 +1,8 @@ import type { TransitionType } from "../../../types/Show" import { send } from "../../utils/request" import { updateTransition } from "../../utils/transitions" -import { clearAudio, startPlaylist, updateVolume } from "../helpers/audio" +import { startMetronome } from "../drawer/audio/metronome" +import { audioPlaylistNext, clearAudio, startPlaylist, updateVolume } from "../helpers/audio" import { displayOutputs } from "../helpers/output" import { activateTrigger, @@ -22,7 +23,7 @@ import { } from "../helpers/showActions" import { stopTimers } from "../helpers/timerTick" import { clearTimers } from "../output/clear" -import { runActionId } from "./actions" +import { runActionId, toggleAction } from "./actions" import { changeVariable, gotoGroup, moveStageConnection, selectOverlayByIndex, selectOverlayByName, selectProjectByIndex, selectShowByName, selectSlideByIndex, selectSlideByName, toggleLock } from "./apiHelper" /// TYPES /// @@ -38,6 +39,7 @@ type API_boolval = { value?: boolean } type API_strval = { value: string } type API_volume = { volume?: number; gain?: number } // no values will mute/unmute type API_slide = { showId?: string | "active"; slideId?: string } +export type API_toggle = { id: string; value?: boolean } export type API_output_style = { outputStyle?: string; styleOutputs?: any } export type API_transition = { id?: "text" | "media" // default: "text" @@ -64,6 +66,12 @@ export type API_midi = { } defaultValues?: boolean // only used by actions } +export type API_metronome = { + tempo?: number + beats?: number + volume?: number + // notesPerBeat?: number +} /// ACTIONS /// @@ -124,7 +132,9 @@ export const API_ACTIONS = { // folder_select_audio: () => , change_volume: (data: API_volume) => updateVolume(data.volume ?? data.gain, data.gain !== undefined), start_audio_stream: (data: API_id) => startAudioStream(data.id), // BC - start_playlist: (data: API_id) => startPlaylist(data.id), + start_playlist: (data: API_id) => startPlaylist(data.id), // BC + playlist_next: () => audioPlaylistNext(), // BC + start_metronome: (data: API_metronome) => startMetronome(data), // BC // TIMERS // play / pause playing timers @@ -143,6 +153,7 @@ export const API_ACTIONS = { start_trigger: (data: API_id) => activateTrigger(data.id), // BC send_midi: (data: API_midi) => sendMidi(data), // BC run_action: (data: API_id) => runActionId(data.id), // BC + toggle_action: (data: API_toggle) => toggleAction(data), // BC } /// RECEIVER / SENDER /// diff --git a/src/frontend/components/actions/apiHelper.ts b/src/frontend/components/actions/apiHelper.ts index 12294985..31b0a692 100644 --- a/src/frontend/components/actions/apiHelper.ts +++ b/src/frontend/components/actions/apiHelper.ts @@ -6,9 +6,9 @@ import { playNextGroup, updateOut } from "../helpers/showActions" import { _show } from "../helpers/shows" import { activeEdit, activePage, activeProject, activeShow, dictionary, groups, outLocked, outputs, overlays, projects, refreshEditSlide, sortedShowsList, variables } from "../../stores" import type { API_variable } from "./api" -import { newToast } from "../../utils/messages" import { send } from "../../utils/request" import { getLabelId } from "../helpers/show" +import { newToast } from "../../utils/common" // WIP combine with click() in ShowButton.svelte export function selectShowByName(name: string) { diff --git a/src/frontend/components/actions/midi.ts b/src/frontend/components/actions/midi.ts index a1239714..1f173d4b 100644 --- a/src/frontend/components/actions/midi.ts +++ b/src/frontend/components/actions/midi.ts @@ -2,36 +2,44 @@ import { get } from "svelte/store" import { MAIN } from "../../../types/Channels" import type { Midi } from "../../../types/Show" import { midiIn, shows } from "../../stores" -import { newToast } from "../../utils/messages" import { send } from "../../utils/request" import { clone } from "../helpers/array" import { setOutput } from "../helpers/output" import { updateOut } from "../helpers/showActions" import { _show } from "../helpers/shows" import { runAction } from "./actions" +import { newToast } from "../../utils/common" +import { loadShows } from "../helpers/setShow" -// WIP MIDI listener export function midiInListen() { - console.log("MIDI IN LISTEN") - Object.entries(get(midiIn)).forEach(([id, action]: any) => { action = convertOldMidiToNewAction(action) if (!action.midi) return if (!action.shows?.length) { + console.info("MIDI INPUT LISTENER: ", action.midi) send(MAIN, ["RECEIVE_MIDI"], { id, ...action.midi }) + return } - action.shows.forEach((show) => { - if (!shows[show.id]) return + action.shows.forEach(async (show) => { + if (!get(shows)[show.id]) return - // find all slides in current show with this MIDI + await loadShows([show.id]) + + // check that current show actually has this MIDI receive action let layouts: any[] = _show(show.id).layouts().get() let found: boolean = false layouts.forEach((layout) => { layout.slides.forEach((slide) => { if (slide.actions?.receiveMidi === id) found = true + + if (slide.children) { + Object.values(slide.children).forEach((child: any) => { + if (child.actions?.receiveMidi === id) found = true + }) + } }) }) @@ -43,6 +51,8 @@ export function midiInListen() { }) } else { if (!action.midi?.input) return + + console.info("MIDI INPUT LISTENER: ", action.midi) send(MAIN, ["RECEIVE_MIDI"], { id, ...action.midi }) } }) @@ -85,19 +95,22 @@ export const defaultMidiActionChannels = { } export function receivedMidi(msg) { - console.log(msg) let msgAction = get(midiIn)[msg.id] if (!msgAction) return let action: Midi = convertOldMidiToNewAction(msgAction) // get index + if (!msg.values) msg.values = {} let index = msg.values.velocity ?? -1 if (action.midi?.values?.velocity !== undefined && action.midi.values.velocity < 0) index = -1 // the select slide index from velocity can't select slide 0 as a NoteOn with velocity 0 is detected as NoteOff // velocity of 0 currently bypasses the note on/off - if (action.midi?.type !== msg.type && index !== 0) return + const diff_type = action.midi?.type !== msg.type + const diff_note = msg.values.note !== action.midi?.values.note + const diff_channel = msg.values.channel !== action.midi?.values.channel + if (!msg.bypass && (diff_type || diff_note || diff_channel) && index !== 0) return let hasindex = action.triggers?.[0]?.includes("index_") ?? false if (hasindex && index < 0) { @@ -114,13 +127,16 @@ export function receivedMidi(msg) { if (!shows?.length) return let slidePlayed: boolean = false - shows.forEach(({ id }) => { + shows.forEach(async ({ id }) => { + await loadShows([id]) let refs = _show(id).layouts().ref() + refs.forEach((ref) => { ref.forEach((slideRef) => { + if (slidePlayed) return + let receiveMidi = slideRef.data.actions?.receiveMidi - if (!receiveMidi) return - if (slidePlayed || receiveMidi !== msg.id) return + if (!receiveMidi || receiveMidi !== msg.id) return // start slide slidePlayed = true diff --git a/src/frontend/components/actions/specific/ChooseStyle.svelte b/src/frontend/components/actions/specific/ChooseStyle.svelte index bc546779..d7e3d279 100644 --- a/src/frontend/components/actions/specific/ChooseStyle.svelte +++ b/src/frontend/components/actions/specific/ChooseStyle.svelte @@ -1,12 +1,12 @@ - -
@@ -61,7 +55,7 @@ position: absolute; left: 50%; top: 50%; - transform: translate(-50%, -50%); + transform: translate(-50%, -58%); pointer-events: none; } diff --git a/src/frontend/components/drawer/audio/Metronome.svelte b/src/frontend/components/drawer/audio/Metronome.svelte new file mode 100644 index 00000000..01cd02d8 --- /dev/null +++ b/src/frontend/components/drawer/audio/Metronome.svelte @@ -0,0 +1,51 @@ + + +
+ + + + + { + values = e.detail + updateMetronome(values) + }} + /> +
+ + diff --git a/src/frontend/components/drawer/audio/MetronomeInputs.svelte b/src/frontend/components/drawer/audio/MetronomeInputs.svelte new file mode 100644 index 00000000..a598722b --- /dev/null +++ b/src/frontend/components/drawer/audio/MetronomeInputs.svelte @@ -0,0 +1,37 @@ + + + +

()

+ updateValue("tempo", e)} /> +
+ +

+ updateValue("beats", e)} /> +
+ +{#if volume} + +

+ updateValue("volume", e)} /> +
+{/if} diff --git a/src/frontend/components/drawer/audio/metronome.ts b/src/frontend/components/drawer/audio/metronome.ts new file mode 100644 index 00000000..8965a77c --- /dev/null +++ b/src/frontend/components/drawer/audio/metronome.ts @@ -0,0 +1,154 @@ +import { get } from "svelte/store" +import { gain, metronome, playingMetronome, volume } from "../../../stores" +import { clone } from "../../helpers/array" +import type { API_metronome } from "../../actions/api" + +const audioContext = new AudioContext() + +const defaultMetronomeValues = { + tempo: 120, // BPM + beats: 4, + volume: 1, + // notesPerBeat: 1 +} +let metronomeValues: API_metronome = {} + +function initializeValues() { + metronomeValues = clone(defaultMetronomeValues) + metronome.set(metronomeValues) +} + +export function toggleMetronome() { + if (get(playingMetronome)) stopMetronome() + else startMetronome() +} + +export function startMetronome(values: API_metronome = {}) { + if (get(metronome)?.tempo) metronomeValues = get(metronome) + if (Object.keys(values).length) { + let oldValues = clone(metronomeValues) + delete oldValues.volume + + updateMetronome(values, true) + + // return if playing and values are the same + let newValues = clone(values) + delete newValues.volume + if (get(playingMetronome) && JSON.stringify(newValues) === JSON.stringify(oldValues)) return + } + + if (!metronomeValues.tempo) initializeValues() + if (get(playingMetronome)) stopMetronome() + + initializeMetronome() +} + +export function updateMetronome(values: API_metronome, starting: boolean = false) { + if (!values.tempo) values.tempo = metronomeValues.tempo || defaultMetronomeValues.tempo + if (!starting && get(playingMetronome) && values.tempo !== metronomeValues.tempo) return startMetronome(values) + + metronomeValues.tempo = values.tempo + if (values.beats) metronomeValues.beats = values.beats + if (values.volume) metronomeValues.volume = values.volume + + metronome.set(metronomeValues) +} + +export function stopMetronome() { + clearTimeout(get(playingMetronome)) + playingMetronome.set(null) + + startTime = 0 + beatsPlayed = 0 +} + +const audioFiles = ["beat-hi", "beat-lo"] +let audioBuffers: any = {} +async function setAudioBuffers() { + if (audioBuffers.hi) return + + await Promise.all( + audioFiles.map(async (fileName) => { + let path = `../assets/${fileName}.mp3` + let id = fileName.slice(fileName.indexOf("-") + 1) + + const audioBuffer = await fetch(path) + .then((res) => res.arrayBuffer()) + .then((ArrayBuffer) => audioContext.decodeAudioData(ArrayBuffer)) + + audioBuffers[id] = audioBuffer + }) + ) +} + +//////////////////// + +// time values are in seconds +let timeBetweenEachBeat = 0 +let startTime = 0 +async function initializeMetronome() { + await setAudioBuffers() + + let beatsPerSecond = 60 / (metronomeValues.tempo || defaultMetronomeValues.tempo) + timeBetweenEachBeat = beatsPerSecond + + scheduleNextNote() +} + +const preScheduleTime = 0.1 +function scheduleNextNote(time = 0, beat = 1) { + if (!startTime) { + startTime = audioContext.currentTime + scheduleNote(beat) + return + } + + if (beat > (metronomeValues.beats || defaultMetronomeValues.beats)) beat = 1 + + playingMetronome.set( + setTimeout(() => { + scheduleNote(beat) + }, (time + timeBetweenEachBeat - preScheduleTime) * 1000) + ) +} + +let beatsPlayed = 0 +function scheduleNote(beat: number) { + beatsPlayed++ + let timeUntilNextNote = getTimeToNextNote() + + playNote(timeUntilNextNote, beat === 1) + scheduleNextNote(timeUntilNextNote, beat + 1) +} + +function getTimeToNextNote() { + let contextTime = audioContext.currentTime + + let nextPlayTime = timeBetweenEachBeat * beatsPlayed + let timePassed = contextTime - startTime + + return nextPlayTime - timePassed +} + +function playNote(time: number, first: boolean = false) { + const source = audioContext.createBufferSource() + const audioBuffer = audioBuffers[first ? "hi" : "lo"] + source.buffer = audioBuffer + + // volume control + let gainNode = audioContext.createGain() + source.connect(gainNode) + gainNode.connect(audioContext.destination) + + // WIP connect getAnalyser() + + gainNode.gain.value = getVolume(first ? accentVolume : secondaryVolume) + + source.start(audioContext.currentTime + time) +} + +let accentVolume = 2 +let secondaryVolume = 1.75 +function getVolume(beatVolume) { + return beatVolume * (metronomeValues.volume || 1) * get(volume) * get(gain) +} diff --git a/src/frontend/components/drawer/bible/Scripture.svelte b/src/frontend/components/drawer/bible/Scripture.svelte index b3761941..b65dc715 100644 --- a/src/frontend/components/drawer/bible/Scripture.svelte +++ b/src/frontend/components/drawer/bible/Scripture.svelte @@ -1,10 +1,10 @@
-
+
{#if notLoaded || !bibles[0]}
@@ -795,7 +805,7 @@
{/if} {:else} -
+
{#if books[firstBibleId]?.length} {#key books[firstBibleId]} {#each books[firstBibleId] as book, i} @@ -816,7 +826,7 @@ {/if}
-
+
{#if chapters[firstBibleId]?.length} {#each chapters[firstBibleId] as chapter, i} {@const id = bibles[0].api ? chapter.id : i} @@ -835,7 +845,7 @@ {/if}
-
+
{#if Object.keys(verses[firstBibleId] || {}).length} {#each Object.entries(verses[firstBibleId] || {}) as [id, content]} diff --git a/src/frontend/components/drawer/bible/scripture.ts b/src/frontend/components/drawer/bible/scripture.ts index 3f07238a..13149693 100644 --- a/src/frontend/components/drawer/bible/scripture.ts +++ b/src/frontend/components/drawer/bible/scripture.ts @@ -6,6 +6,7 @@ import { getAutoSize } from "../../edit/scripts/autoSize" import { clone, removeDuplicates } from "../../helpers/array" const api = "https://api.scripture.api.bible/v1/bibles/" +let tempCache: any = {} export async function fetchBible(load: string, active: string, ref: any = { versesList: [], bookId: "GEN", chapterId: "GEN.1" }) { let versesId: any = null if (ref.versesList.length) { @@ -21,6 +22,8 @@ export async function fetchBible(load: string, active: string, ref: any = { vers versesText: `${api}${active}/verses/${versesId}`, } + if (tempCache[urls[load]]) return tempCache[urls[load]] + return new Promise((resolve, reject) => { if (!get(bibleApiKey)) return reject("No API key!") if (urls[load].includes("null")) return reject("Something went wrong!") @@ -28,6 +31,7 @@ export async function fetchBible(load: string, active: string, ref: any = { vers fetch(urls[load], { headers: { "api-key": get(bibleApiKey) } }) .then((response) => response.json()) .then((data) => { + tempCache[urls[load]] = data.data resolve(data.data) }) .catch((e) => { @@ -126,12 +130,19 @@ export function getSlides({ bibles, sorted }) { sorted.forEach((s: any, i: number) => { let slideArr: any = slides[slideIndex][bibleIndex] + let lineIndex: number = 0 + // verses on individual lines + if (get(scriptureSettings).versesOnIndividualLines) { + lineIndex = i + if (!slideArr.lines![lineIndex]) slideArr.lines![lineIndex] = { text: [], align: alignStyle } + } + if (get(scriptureSettings).verseNumbers) { let size = get(scriptureSettings).numberSize || 50 if (i === 0) size *= 1.2 let verseNumberStyle = textStyle + "font-size: " + size + "px;color: " + (get(scriptureSettings).numberColor || "#919191") - slideArr.lines![0].text.push({ + slideArr.lines![lineIndex].text.push({ value: s + " ", style: verseNumberStyle, customType: "disableTemplate", // dont let template style verse numbers @@ -147,6 +158,7 @@ export function getSlides({ bibles, sorted }) { if (get(scriptureSettings).redJesus) { let jesusWords: any[] = [] let jesusStart = text.indexOf(' -1) { let jesusEnd = 0 @@ -167,6 +179,7 @@ export function getSlides({ bibles, sorted }) { if (jesusEnd) { jesusWords.push([jesusStart, jesusEnd]) jesusStart = text.indexOf(' 0) return @@ -252,8 +266,10 @@ export function getSlides({ bibles, sorted }) { if (get(scriptureSettings).combineWithText) itemIndex = 0 let metaTemplate = templateTextItems[itemIndex] || templateTextItems[0] + let alignStyle = metaTemplate?.lines?.[0]?.align || "" let verseStyle = metaTemplate?.lines?.[0]?.text?.[0]?.style || "font-size: 50px;" - let versions = bibles.map((a) => a.version).join(" + ") + // remove text in () on scripture names + let versions = bibles.map((a) => a.version.replace(/\([^)]*\)/g, "").trim()).join(" + ") let books = removeDuplicates(bibles.map((a) => a.book)).join(" / ") let text = customText @@ -262,7 +278,7 @@ export function getSlides({ bibles, sorted }) { if (showVerse) text = text.replaceAll(textKeys.showVerse, books + " " + bibles[0].chapter + ":" + range) text.split("\n").forEach((line) => { - lines.push({ text: [{ value: line, style: verseStyle }], align: "" }) + lines.push({ text: [{ value: line, style: verseStyle }], align: alignStyle }) }) if (lines.length) { diff --git a/src/frontend/components/drawer/calendar/Calendar.svelte b/src/frontend/components/drawer/calendar/Calendar.svelte index 8f4e690a..4a9c94d2 100644 --- a/src/frontend/components/drawer/calendar/Calendar.svelte +++ b/src/frontend/components/drawer/calendar/Calendar.svelte @@ -1,5 +1,6 @@
@@ -203,7 +210,7 @@ {/each}
-
+
{#each days as week}
@@ -224,11 +231,13 @@ {day.getDate()} {#each dayEvents as event, i} + {@const eventIcon = getEventIcon(event.type, { actionId: event.action?.id })} + {#if dayEvents.length > 3 && i > 1} {:else}
- +

{event.name}

{/if} diff --git a/src/frontend/components/drawer/calendar/Day.svelte b/src/frontend/components/drawer/calendar/Day.svelte index 78a8b8a8..8ef8b0bf 100644 --- a/src/frontend/components/drawer/calendar/Day.svelte +++ b/src/frontend/components/drawer/calendar/Day.svelte @@ -1,5 +1,7 @@ {#if $activeDays.length} @@ -39,10 +47,14 @@
{#if currentEvents.length} {#each currentEvents as event} + {@const eventIcon = getEventIcon(event.type, { actionId: event.action?.id })} + {@const customName = type === "action" ? getActionName(event.action?.id, event.action?.data) : ""} +
{ eventEdit.set(event.id) activePopup.set("edit_event") @@ -65,10 +77,14 @@ {/if}
- +

{#if event.name} - {event.name} + {#if customName} + {event.name}: {customName} + {:else} + {event.name} + {/if} {:else} diff --git a/src/frontend/components/drawer/calendar/event.ts b/src/frontend/components/drawer/calendar/event.ts index de90faeb..4ee4c086 100644 --- a/src/frontend/components/drawer/calendar/event.ts +++ b/src/frontend/components/drawer/calendar/event.ts @@ -1,7 +1,9 @@ import { get } from "svelte/store" import { uid } from "uid" import type { Event } from "../../../../types/Calendar" -import { events, shows } from "../../../stores" +import { events } from "../../../stores" +import { translate } from "../../../utils/language" +import { actionData } from "../../actions/actionData" import { clone } from "../../helpers/array" import { history } from "../../helpers/history" @@ -111,7 +113,7 @@ const setDate = (date: Date, options: any): Date => { return new Date([...newDate, time].join(" ")) } -export function updateEventData(editEvent: any, stored: any, { type, show }: any): any { +export function updateEventData(editEvent: any, stored: any, { type, action }: any): any { let data = clone(editEvent) let oldData = JSON.parse(stored) let id = editEvent.id @@ -120,8 +122,8 @@ export function updateEventData(editEvent: any, stored: any, { type, show }: any oldData.from = new Date(oldData.isoFrom + " " + (oldData.time ? oldData.fromTime : "")) data.to = new Date(editEvent.isoTo + " " + (editEvent ? editEvent.toTime : "")) oldData.to = new Date(oldData.isoTo + " " + (oldData ? oldData.toTime : "")) - data.type = type.id - oldData.type = type.id + data.type = type + oldData.type = type // to has to be after from if (data.to.getTime() - data.from.getTime() <= 0) data.to = data.from @@ -132,17 +134,17 @@ export function updateEventData(editEvent: any, stored: any, { type, show }: any delete oldData[key] }) - if (data.type === "show") data = getShowEventData(data, show.id) + if (data.type === "action") data = getActionEventData(data, action) return { data, oldData, id } } -// show -export function getShowEventData(event: any, showId: string) { - let show: any = get(shows)[showId] +// action +export function getActionEventData(event: any, action: any) { + let actionName = translate(actionData[action.id]?.name) - event.show = showId - event.name = show.name + event.action = action + event.name = actionName event.color = null delete event.location delete event.notes diff --git a/src/frontend/components/drawer/info/AudioInfo.svelte b/src/frontend/components/drawer/info/AudioInfo.svelte new file mode 100644 index 00000000..ab7f6369 --- /dev/null +++ b/src/frontend/components/drawer/info/AudioInfo.svelte @@ -0,0 +1,95 @@ + + + + +{#if openedPage === "settings"} +

+ +

+ updateSpecial(e.detail, "audio_fade_duration")} /> +
+ + +

+
+ updateSpecial(isChecked(e), "muteAudioWhenVideoPlays")} /> +
+
+
+{:else if isPlaylist} +
+ +

+ updatePlaylist(active, "crossfade", e.detail)} /> +
+ + +
+{:else if openedPage === "metronome"} + +{:else} + +{/if} + +{#if !isPlaylist} + +{/if} + + diff --git a/src/frontend/components/drawer/info/CalendarInfo.svelte b/src/frontend/components/drawer/info/CalendarInfo.svelte index 2e2cf306..efdee208 100644 --- a/src/frontend/components/drawer/info/CalendarInfo.svelte +++ b/src/frontend/components/drawer/info/CalendarInfo.svelte @@ -1,5 +1,5 @@ @@ -193,10 +199,20 @@ update("template", e.detail.id)} style="width: 30%;" /> - -

- update("versesPerSlide", e.detail)} buttons={false} /> -
+ {#if $scriptureSettings.versesPerSlide != 3 || sorted.length > 1} + +

+ update("versesPerSlide", e.detail)} buttons={false} /> +
+ {/if} + {#if $scriptureSettings.versesOnIndividualLines || sorted.length > 1} + +

+
+ +
+
+ {/if}

@@ -215,12 +231,14 @@
{/if} - -

-
- -
-
+ {#if $scriptureSettings.redJesus || containsJesusWords} + +

+
+ +
+
+ {/if} {#if $scriptureSettings.redJesus}

diff --git a/src/frontend/components/drawer/live/recorder.ts b/src/frontend/components/drawer/live/recorder.ts index 98defa19..ea6d9c26 100644 --- a/src/frontend/components/drawer/live/recorder.ts +++ b/src/frontend/components/drawer/live/recorder.ts @@ -1,7 +1,7 @@ import { get } from "svelte/store" import { RECORDER } from "../../../../types/Channels" import { activeRecording, currentRecordingStream, dataPath } from "../../../stores" -import { newToast } from "../../../utils/messages" +import { newToast } from "../../../utils/common" let mediaRecorder let recordedChunks: any[] = [] diff --git a/src/frontend/components/drawer/media/Image.svelte b/src/frontend/components/drawer/media/Image.svelte index 2c047f80..2518f898 100644 --- a/src/frontend/components/drawer/media/Image.svelte +++ b/src/frontend/components/drawer/media/Image.svelte @@ -13,9 +13,27 @@ loaded = true } }) + + // retry on error + let retryCount = 0 + $: if (src) retryCount = 0 + function reload() { + if (retryCount > 5) { + loaded = true + return + } + loaded = false + + let time = 500 * (retryCount + 1) + setTimeout(() => { + retryCount++ + }, time) + } - +{#key retryCount} + +{/key} diff --git a/src/frontend/components/drawer/media/MediaLoader.svelte b/src/frontend/components/drawer/media/MediaLoader.svelte index 22a57498..1636d8f8 100644 --- a/src/frontend/components/drawer/media/MediaLoader.svelte +++ b/src/frontend/components/drawer/media/MediaLoader.svelte @@ -3,16 +3,16 @@ import type { Resolution } from "../../../../types/Settings" import type { MediaType, ShowType } from "../../../../types/Show" - import { mediaCache, outputs, styles, videoExtensions } from "../../../stores" + import { outputs, styles, videoExtensions } from "../../../stores" import { getExtension } from "../../helpers/media" import { getResolution } from "../../helpers/output" import Camera from "../../output/Camera.svelte" import { getStyleResolution } from "../../slide/getStyleResolution" import Capture from "../live/Capture.svelte" - // import Image from "./Image.svelte" export let name: any = "" export let path: string + export let thumbnailPath: string = "" export let loadFullImage: boolean = false export let cameraGroup: string = "" export let mediaStyle: MediaStyle = {} @@ -22,87 +22,11 @@ export let resolution: Resolution | null = null export let duration: number = 0 export let videoElem: any = null - export let ghost: boolean = false - // TODO: update - $: if ((!type || type === "image") && canvas) { - duration = 0 - - // image cache - loadImage() - } - - // const thumbnailSize = { width: 1920, height: 1080 } - const thumbnailSize = { width: 480, height: 270 } - // const thumbnailSize = { width: 320, height: 180 } - // const thumbnailSize = {width: 240, height: 135} - // const thumbnailSize = { width: 160, height: 90 } - // const thumbnailSize = { width: 80, height: 45 } - let storedSize: any = {} - - function loadImage() { - img = new Image() - - let cache = $mediaCache[path] - let src: string = path - if (cache) src = cache.data - - img.src = src - canvas.width = cache ? cache.size?.width || cache.width || 160 : thumbnailSize.width - canvas.height = cache ? cache.size?.height || cache.height || 90 : thumbnailSize.height - - storedSize = cache ? cache.size || { width: cache.width, height: cache.height } : {} - - if (cache) checkIfCacheLoaded() - else checkIfLoaded() - } - - let img: any = null - function checkIfLoaded(): any { - if (!img.complete) return setTimeout(checkIfLoaded, 100) - - let width = img.naturalWidth || thumbnailSize.width - let height = img.naturalHeight || thumbnailSize.height - let x = 0 - let y = 0 - - if (width / height > customResolution.width / customResolution.height) { - height = height / (width / thumbnailSize.width) - width = thumbnailSize.width - y = (thumbnailSize.height - height) / 2 - } else { - width = width / (height / thumbnailSize.height) - height = thumbnailSize.height - x = (thumbnailSize.width - width) / 2 - } - - // canvas?.getContext("2d").drawImage(img, 0, 0, 160, 90) - setTimeout(() => { - canvas?.getContext("2d").drawImage(img, x, y, width, height) - setTimeout(() => { - if (canvas) { - mediaCache.update((a) => { - a[path] = { data: canvas.toDataURL(), size: { width, height } } - // a[path] = { data: canvas.toDataURL(), width, height, x, y } - return a - }) - // canvas?.getContext("2d").clearRect(0, 0, 160, 90) - // cavas?.getContext("2d").drawImage(img, x, y, width, height) - - loaded = true - } - }, 100) - }, 50) - } - - function checkIfCacheLoaded(): any { - if (!img.complete) return setTimeout(checkIfCacheLoaded, 100) + $: if (path) loaded = false - // let cache = $mediaCache[path] - // canvas?.getContext("2d").drawImage(img, cache.x, cache.y, cache.width, cache.height) - canvas?.getContext("2d").drawImage(img, 0, 0, storedSize.width || 160, storedSize.height || 90) - loaded = true - } + let width: number = 0 + let height: number = 0 // type $: if (!type && path) { @@ -110,90 +34,57 @@ if ($videoExtensions.includes(extension)) type = "video" } - $: if (path) loaded = false - - let canvas: any - - let time = false - function ready() { - if (loaded || !videoElem) return - - // check cache - let cache: any = $mediaCache[path] - if (cache) { - // TODO: cache - var img = new window.Image() - // console.log(cache) - img.src = cache.data - - canvas.width = cache.size?.width || cache.width - canvas.height = cache.size?.height || cache.height - setTimeout(() => { - canvas?.getContext("2d").drawImage(img, 0, 0, cache.width, cache.height) - // canvas.getContext("2d").drawImage(videoElem, 0, 0, cache.width, cache.height) - }, 200) - - duration = videoElem.duration - videoElem.currentTime = duration / 2 - time = true - - loaded = true - return - } + $: customResolution = resolution || getResolution(null, { $outputs, $styles }) - if (ghost) return + $: if (mediaStyle.speed && videoElem) videoElem.playbackRate = mediaStyle.speed - if (!time) { - duration = videoElem.duration - // it's sometimes Infinity for some reason - if (duration === Infinity) duration = 0 - videoElem.currentTime = duration / 2 - time = true - } else { - let width = videoElem.offsetWidth - let height = videoElem.offsetHeight - canvas.width = width - canvas.height = height - canvas.getContext("2d").drawImage(videoElem, 0, 0, width, height) + $: if (!videoElem) duration = 0 + function getDuration() { + if (!videoElem || duration) return - // set cache - setTimeout(() => { - if (!canvas) return - mediaCache.update((a: any) => { - a[path] = { data: canvas.toDataURL(), width, height, size: { width, height } } - return a - }) + duration = videoElem.duration - loaded = true - }, 1000) - } + // set video time + if (hover || !useOriginal) return + videoElem.currentTime = duration / 2 } - let width: number = 0 - let height: number = 0 - - $: customResolution = resolution || getResolution(null, { $outputs, $styles }) - - $: if (mediaStyle.speed && videoElem) videoElem.playbackRate = mediaStyle.speed - - // retry on error (don't think this is neccesary) + // retry on error let retryCount = 0 - $: if (path) retryCount = 0 + $: if (path || thumbnailPath) retryCount = 0 function reload() { - if (ghost || retryCount > 5) return + if (retryCount > 5) { + loaded = true + return + } + loaded = false + let time = 500 * (retryCount + 1) setTimeout(() => { - loaded = false retryCount++ - }, 100) + }, time) } // path starting at "/" auto completes to app root, but should be file:// $: if (path[0] === "/") path = `file://${path}` + + $: useOriginal = hover || loadFullImage || retryCount > 5 || !thumbnailPath + + // get duration + $: if (type === "video" && thumbnailPath) getVideoDuration() + function getVideoDuration() { + let video = document.createElement("video") + video.onloadeddata = () => { + duration = video.duration || 0 + // video.pause() + video.src = "" + } + video.src = path + }
- {#key path || retryCount} + {#key path} {#if type === "camera"}
@@ -201,44 +92,33 @@
{:else if type === "screen"} - {:else if type === "video"} -
- - {#if !loaded || hover || loadFullImage} - {#key retryCount} - - {/key} - {/if} -
{:else} - {#if !loadFullImage || !loaded} - - {/if} - {#if loadFullImage} + {#if type !== "video" || (thumbnailPath && retryCount <= 5)} {#key retryCount} {name} (loaded = true)} /> {/key} {/if} + {#if type === "video" && useOriginal} + + {/if} {/if} {/key}
diff --git a/src/frontend/components/edit/tools/Items.svelte b/src/frontend/components/edit/tools/Items.svelte index 2160ca22..4d9ee097 100644 --- a/src/frontend/components/edit/tools/Items.svelte +++ b/src/frontend/components/edit/tools/Items.svelte @@ -5,6 +5,7 @@ import { history } from "../../helpers/history" import Icon from "../../helpers/Icon.svelte" import { getFileName } from "../../helpers/media" + import { sortItemsByType } from "../../helpers/output" import { _show } from "../../helpers/shows" import T from "../../helpers/T.svelte" import Button from "../../inputs/Button.svelte" @@ -15,7 +16,7 @@ import { getItemText } from "../scripts/textStyle" import { boxes } from "../values/boxes" - const items: { id: ItemType; icon?: string; name?: string }[] = [ + const items: { id: ItemType; icon?: string; name?: string; maxAmount?: number }[] = [ { id: "text" }, { id: "list" }, // { id: "table" }, @@ -27,7 +28,8 @@ { id: "variable" }, { id: "web" }, { id: "mirror" }, - { id: "visualizer" }, + { id: "visualizer", maxAmount: 1 }, + { id: "captions", maxAmount: 1 }, // max one because there can't be multiple translations at this point ] const getIdentifier = { @@ -104,13 +106,15 @@ } const getType = (item: any) => (item.type as ItemType) || "text" + + $: sortedItems = sortItemsByType(invertedItemList)
{#each items as item} - addItem(item.id)} /> + = item.maxAmount} on:click={() => addItem(item.id)} /> {/each}
@@ -144,6 +148,7 @@ dark on:click={(e) => { if (!e.target?.closest(".up")) { + selected.set({ id: null, data: [] }) activeEdit.update((ae) => { if (e.ctrlKey || e.metaKey) { if (ae.items.includes(index)) ae.items.splice(ae.items.indexOf(index), 1) diff --git a/src/frontend/components/edit/tools/TemplateStyle.svelte b/src/frontend/components/edit/tools/TemplateStyle.svelte new file mode 100644 index 00000000..73c8f94f --- /dev/null +++ b/src/frontend/components/edit/tools/TemplateStyle.svelte @@ -0,0 +1,119 @@ + + +
+ +

+ setValue(e, "backgroundColor")} /> +
+ +

+ setValue(e, "backgroundPath")}> + + {#if settings.backgroundPath} +

{getFileName(settings.backgroundPath)}

+ {:else} +

+ {/if} +
+
+ + +

+ setValue(e?.detail?.id, "overlayId")} /> +
+ + +

+ setValue(e?.detail?.id, "firstSlideTemplate")} /> +
+ +
+ +

+ setResolution(e, "width")} buttons={false} /> +
+ +

+ setResolution(e, "height")} buttons={false} /> +
+
+ + diff --git a/src/frontend/components/edit/values/boxes.ts b/src/frontend/components/edit/values/boxes.ts index f9fa1cfc..5a0c4456 100644 --- a/src/frontend/components/edit/values/boxes.ts +++ b/src/frontend/components/edit/values/boxes.ts @@ -1,4 +1,5 @@ import type { ItemType } from "./../../../../types/Show" +import { captionLanguages } from "./captionLanguages" export type Box = { [key in ItemType]?: { @@ -441,6 +442,22 @@ export const boxes: Box = { // ], }, }, + captions: { + icon: "captions", + edit: { + default: [ + { name: "captions.language", id: "captions.language", input: "dropdown", value: "en-US", values: { options: captionLanguages } }, + // this is very limited + // { name: "captions.translate", id: "captions.translate", input: "dropdown", value: "en-US", values: { options: captionTranslateLanguages } }, + { name: "captions.showtime", id: "captions.showtime", input: "number", value: 5, values: { min: 1, max: 60 } }, + // label? + { name: "captions.powered_by", values: { subtext: "CAPTION.Ninja" }, input: "tip" }, + ], + // WIP custom inputs for the css + // https://github.com/steveseguin/captionninja?tab=readme-ov-file#changing-the-font-size-and-more + CSS: [{ id: "captions.style", input: "CSS" }], + }, + }, icon: { icon: "icon", edit: { diff --git a/src/frontend/components/edit/values/captionLanguages.ts b/src/frontend/components/edit/values/captionLanguages.ts new file mode 100644 index 00000000..90674fb5 --- /dev/null +++ b/src/frontend/components/edit/values/captionLanguages.ts @@ -0,0 +1,182 @@ +// auto get a list with all languages +// let langs = {}; +// [...(document.querySelector(".list").children)].forEach(tr => { +// let name = tr.children[0].innerText +// let id = tr.children[1].innerText +// langs[id] = name +// }) +// console.log(Object.entries(langs).map(([id, name]) => ({id, name}))) + +// https://cloud.google.com/speech-to-text/docs/languages +export const captionLanguages = [ + { id: "af-ZA", name: "Afrikaans (South Africa)" }, + { id: "sq-AL", name: "Albanian (Albania)" }, + { id: "am-ET", name: "Amharic (Ethiopia)" }, + { id: "ar-DZ", name: "Arabic (Algeria)" }, + { id: "ar-BH", name: "Arabic (Bahrain)" }, + { id: "ar-EG", name: "Arabic (Egypt)" }, + { id: "ar-IQ", name: "Arabic (Iraq)" }, + { id: "ar-IL", name: "Arabic (Israel)" }, + { id: "ar-JO", name: "Arabic (Jordan)" }, + { id: "ar-KW", name: "Arabic (Kuwait)" }, + { id: "ar-LB", name: "Arabic (Lebanon)" }, + { id: "ar-MR", name: "Arabic (Mauritania)" }, + { id: "ar-MA", name: "Arabic (Morocco)" }, + { id: "ar-OM", name: "Arabic (Oman)" }, + { id: "ar-QA", name: "Arabic (Qatar)" }, + { id: "ar-SA", name: "Arabic (Saudi Arabia)" }, + { id: "ar-PS", name: "Arabic (State of Palestine)" }, + { id: "ar-SY", name: "Arabic (Syria)" }, + { id: "ar-TN", name: "Arabic (Tunisia)" }, + { id: "ar-AE", name: "Arabic (United Arab Emirates)" }, + { id: "ar-YE", name: "Arabic (Yemen)" }, + { id: "hy-AM", name: "Armenian (Armenia)" }, + { id: "az-AZ", name: "Azerbaijani (Azerbaijan)" }, + { id: "eu-ES", name: "Basque (Spain)" }, + { id: "bn-BD", name: "Bengali (Bangladesh)" }, + { id: "bn-IN", name: "Bengali (India)" }, + { id: "bs-BA", name: "Bosnian (Bosnia and Herzegovina)" }, + { id: "bg-BG", name: "Bulgarian (Bulgaria)" }, + { id: "my-MM", name: "Burmese (Myanmar)" }, + { id: "ca-ES", name: "Catalan (Spain)" }, + { id: "cmn-Hans-CN", name: "Chinese (Simplified, China)" }, + { id: "cmn-Hans-HK", name: "Chinese (Simplified, Hong Kong)" }, + { id: "cmn-Hant-TW", name: "Chinese (Traditional, Taiwan)" }, + { id: "yue-Hant-HK", name: "Chinese, Cantonese (Traditional Hong Kong)" }, + { id: "hr-HR", name: "Croatian (Croatia)" }, + { id: "cs-CZ", name: "Czech (Czech Republic)" }, + { id: "da-DK", name: "Danish (Denmark)" }, + { id: "nl-BE", name: "Dutch (Belgium)" }, + { id: "nl-NL", name: "Dutch (Netherlands)" }, + { id: "en-AU", name: "English (Australia)" }, + { id: "en-CA", name: "English (Canada)" }, + { id: "en-GH", name: "English (Ghana)" }, + { id: "en-HK", name: "English (Hong Kong)" }, + { id: "en-IN", name: "English (India)" }, + { id: "en-IE", name: "English (Ireland)" }, + { id: "en-KE", name: "English (Kenya)" }, + { id: "en-NZ", name: "English (New Zealand)" }, + { id: "en-NG", name: "English (Nigeria)" }, + { id: "en-PK", name: "English (Pakistan)" }, + { id: "en-PH", name: "English (Philippines)" }, + { id: "en-SG", name: "English (Singapore)" }, + { id: "en-ZA", name: "English (South Africa)" }, + { id: "en-TZ", name: "English (Tanzania)" }, + { id: "en-GB", name: "English (United Kingdom)" }, + { id: "en-US", name: "English (United States)" }, + { id: "et-EE", name: "Estonian (Estonia)" }, + { id: "fil-PH", name: "Filipino (Philippines)" }, + { id: "fi-FI", name: "Finnish (Finland)" }, + { id: "fr-BE", name: "French (Belgium)" }, + { id: "fr-CA", name: "French (Canada)" }, + { id: "fr-FR", name: "French (France)" }, + { id: "fr-CH", name: "French (Switzerland)" }, + { id: "gl-ES", name: "Galician (Spain)" }, + { id: "ka-GE", name: "Georgian (Georgia)" }, + { id: "de-AT", name: "German (Austria)" }, + { id: "de-DE", name: "German (Germany)" }, + { id: "de-CH", name: "German (Switzerland)" }, + { id: "el-GR", name: "Greek (Greece)" }, + { id: "gu-IN", name: "Gujarati (India)" }, + { id: "iw-IL", name: "Hebrew (Israel)" }, + { id: "hi-IN", name: "Hindi (India)" }, + { id: "hu-HU", name: "Hungarian (Hungary)" }, + { id: "is-IS", name: "Icelandic (Iceland)" }, + { id: "id-ID", name: "Indonesian (Indonesia)" }, + { id: "it-IT", name: "Italian (Italy)" }, + { id: "it-CH", name: "Italian (Switzerland)" }, + { id: "ja-JP", name: "Japanese (Japan)" }, + { id: "jv-ID", name: "Javanese (Indonesia)" }, + { id: "kn-IN", name: "Kannada (India)" }, + { id: "kk-KZ", name: "Kazakh (Kazakhstan)" }, + { id: "km-KH", name: "Khmer (Cambodia)" }, + { id: "rw-RW", name: "Kinyarwanda (Rwanda)" }, + { id: "ko-KR", name: "Korean (South Korea)" }, + { id: "lo-LA", name: "Lao (Laos)" }, + { id: "lv-LV", name: "Latvian (Latvia)" }, + { id: "lt-LT", name: "Lithuanian (Lithuania)" }, + { id: "mk-MK", name: "Macedonian (North Macedonia)" }, + { id: "ms-MY", name: "Malay (Malaysia)" }, + { id: "ml-IN", name: "Malayalam (India)" }, + { id: "mr-IN", name: "Marathi (India)" }, + { id: "mn-MN", name: "Mongolian (Mongolia)" }, + { id: "ne-NP", name: "Nepali (Nepal)" }, + { id: "no-NO", name: "Norwegian Bokmål (Norway)" }, + { id: "fa-IR", name: "Persian (Iran)" }, + { id: "pl-PL", name: "Polish (Poland)" }, + { id: "pt-BR", name: "Portuguese (Brazil)" }, + { id: "pt-PT", name: "Portuguese (Portugal)" }, + { id: "pa-Guru-IN", name: "Punjabi (Gurmukhi India)" }, + { id: "ro-RO", name: "Romanian (Romania)" }, + { id: "ru-RU", name: "Russian (Russia)" }, + { id: "sr-RS", name: "Serbian (Serbia)" }, + { id: "si-LK", name: "Sinhala (Sri Lanka)" }, + { id: "sk-SK", name: "Slovak (Slovakia)" }, + { id: "sl-SI", name: "Slovenian (Slovenia)" }, + { id: "st-ZA", name: "Southern Sotho (South Africa)" }, + { id: "es-AR", name: "Spanish (Argentina)" }, + { id: "es-BO", name: "Spanish (Bolivia)" }, + { id: "es-CL", name: "Spanish (Chile)" }, + { id: "es-CO", name: "Spanish (Colombia)" }, + { id: "es-CR", name: "Spanish (Costa Rica)" }, + { id: "es-DO", name: "Spanish (Dominican Republic)" }, + { id: "es-EC", name: "Spanish (Ecuador)" }, + { id: "es-SV", name: "Spanish (El Salvador)" }, + { id: "es-GT", name: "Spanish (Guatemala)" }, + { id: "es-HN", name: "Spanish (Honduras)" }, + { id: "es-MX", name: "Spanish (Mexico)" }, + { id: "es-NI", name: "Spanish (Nicaragua)" }, + { id: "es-PA", name: "Spanish (Panama)" }, + { id: "es-PY", name: "Spanish (Paraguay)" }, + { id: "es-PE", name: "Spanish (Peru)" }, + { id: "es-PR", name: "Spanish (Puerto Rico)" }, + { id: "es-ES", name: "Spanish (Spain)" }, + { id: "es-US", name: "Spanish (United States)" }, + { id: "es-UY", name: "Spanish (Uruguay)" }, + { id: "es-VE", name: "Spanish (Venezuela)" }, + { id: "su-ID", name: "Sundanese (Indonesia)" }, + { id: "sw-KE", name: "Swahili (Kenya)" }, + { id: "sw-TZ", name: "Swahili (Tanzania)" }, + { id: "ss-Latn-ZA", name: "Swati (Latin, South Africa)" }, + { id: "sv-SE", name: "Swedish (Sweden)" }, + { id: "ta-IN", name: "Tamil (India)" }, + { id: "ta-MY", name: "Tamil (Malaysia)" }, + { id: "ta-SG", name: "Tamil (Singapore)" }, + { id: "ta-LK", name: "Tamil (Sri Lanka)" }, + { id: "te-IN", name: "Telugu (India)" }, + { id: "th-TH", name: "Thai (Thailand)" }, + { id: "ts-ZA", name: "Tsonga (South Africa)" }, + { id: "tn-Latn-ZA", name: "Tswana (Latin, South Africa)" }, + { id: "tr-TR", name: "Turkish (Turkey)" }, + { id: "uk-UA", name: "Ukrainian (Ukraine)" }, + { id: "ur-IN", name: "Urdu (India)" }, + { id: "ur-PK", name: "Urdu (Pakistan)" }, + { id: "uz-UZ", name: "Uzbek (Uzbekistan)" }, + { id: "ve-ZA", name: "Venda (South Africa)" }, + { id: "vi-VN", name: "Vietnamese (Vietnam)" }, + { id: "xh-ZA", name: "Xhosa (South Africa)" }, + { id: "zu-ZA", name: "Zulu (South Africa)" }, +] + +// https://github.com/mozilla/translate - MPL 2.0 - Mozilla +export const captionTranslateLanguages = [ + // { id: "none", name: "$:main.none:$" }, + { id: "", name: "—" }, + { id: "bg", name: "Bulgarian" }, + { id: "cs", name: "Czech" }, + { id: "nl", name: "Dutch" }, + { id: "en", name: "English" }, + { id: "et", name: "Estonian" }, + { id: "de", name: "German" }, + { id: "fr", name: "French" }, + { id: "is", name: "Icelandic" }, + { id: "it", name: "Italian" }, + { id: "nb", name: "Norwegian Bokmål" }, + { id: "nn", name: "Norwegian Nynorsk" }, + { id: "fa", name: "Persian" }, + { id: "pl", name: "Polish" }, + { id: "pt", name: "Portuguese" }, + { id: "ru", name: "Russian" }, + { id: "es", name: "Spanish" }, + { id: "uk", name: "Ukrainian" }, +] diff --git a/src/frontend/components/helpers/audio.ts b/src/frontend/components/helpers/audio.ts index 96e1da27..c944dd7e 100644 --- a/src/frontend/components/helpers/audio.ts +++ b/src/frontend/components/helpers/audio.ts @@ -1,13 +1,15 @@ import { get } from "svelte/store" +import { uid } from "uid" import { MAIN, OUTPUT } from "../../../types/Channels" -import { activePlaylist, audioChannels, audioPlaylists, gain, media, playingAudio, playingVideos, special, volume } from "../../stores" +import { activePlaylist, audioChannels, audioPlaylists, gain, media, outLocked, playingAudio, playingVideos, special, volume } from "../../stores" import { send } from "../../utils/request" +import { stopMetronome } from "../drawer/audio/metronome" import { audioAnalyser } from "../output/audioAnalyser" import { clone, shuffleArray } from "./array" import { encodeFilePath } from "./media" import { checkNextAfterMedia } from "./showActions" -export async function playAudio({ path, name = "", audio = null, stream = null }: any, pauseIfPlaying: boolean = true, startAt: number = 0, playMultiple: boolean = false) { +export async function playAudio({ path, name = "", audio = null, stream = null }: any, pauseIfPlaying: boolean = true, startAt: number = 0, playMultiple: boolean = false, crossfade: number = 0) { let existing: any = get(playingAudio)[path] if (existing) { if (!pauseIfPlaying) { @@ -28,7 +30,8 @@ export async function playAudio({ path, name = "", audio = null, stream = null } return } - if (!playMultiple) clearAudio("", false) + if (crossfade) crossfadeAudio(crossfade) + else if (!playMultiple) clearAudio("", false) let encodedPath = encodeFilePath(path) audio = audio || new Audio(encodedPath) @@ -52,12 +55,48 @@ export async function playAudio({ path, name = "", audio = null, stream = null } if (analyser.gainNode) analyser.gainNode.gain.value = localVolume * (get(gain) || 1) else audio.volume = localVolume + if (crossfade) { + audio.volume = 0 + crossfadeAudio(crossfade, path) + } + if (startAt > 0) audio.currentTime = startAt audio.play() analyseAudio() } +// if no "path" is provided it will fade out/clear all audio +async function crossfadeAudio(crossfade: number = 0, path: string = "") { + // fade in + if (path) { + let playing = get(playingAudio)[path]?.audio + if (!playing) return + + fadeAudio(playing, crossfade, true) + return + } + + // fade out + Object.entries(get(playingAudio)).forEach(([path, { audio }]) => { + fadeoutAudio(audio, path) + }) + + async function fadeoutAudio(audio, path) { + let faded = await fadeAudio(audio, crossfade) + if (faded) deleteAudio(path) + } + + function deleteAudio(path) { + playingAudio.update((a) => { + a[path]?.audio?.pause() + delete a[path] + + return a + }) + } +} + let unmutedValue = 1 export function updateVolume(value: number | undefined | "local", changeGain: boolean = false) { if (value !== "local") { @@ -111,17 +150,26 @@ export function updatePlaylist(id: string, key: string, value: any) { }) } -export function playlistNext(previous: string = "", specificSong: string = "") { +export function audioPlaylistNext() { + if (get(outLocked) || !get(activePlaylist)?.id) return + + let playlistId = get(activePlaylist).id || "" + let playlist = get(audioPlaylists)[playlistId] || {} + let crossfade = Number(playlist.crossfade) || 0 + + let activePath = get(activePlaylist).active + playlistNext(activePath, "", crossfade) +} + +export function playlistNext(previous: string = "", specificSong: string = "", crossfade: number = 0) { let id = get(activePlaylist)?.id - console.log("next", previous, get(activePlaylist), get(audioPlaylists)) if (!id) return let songs = getSongs() - console.log(songs) + if (!songs.length) return let currentSongIndex = songs.findIndex((a) => a === (specificSong || previous)) let nextSong = songs[currentSongIndex + (specificSong ? 0 : 1)] - console.log(currentSongIndex, nextSong) if (!nextSong) nextSong = songs[0] if (!nextSong) return @@ -130,13 +178,16 @@ export function playlistNext(previous: string = "", specificSong: string = "") { a.active = nextSong return a }) - playAudio({ path: nextSong }) + + if (crossfade) isCrossfading = true + playAudio({ path: nextSong }, false, 0, false, crossfade) function getSongs(): string[] { if (previous && get(activePlaylist)?.songs) return get(activePlaylist).songs // generate list let playlist = clone(get(audioPlaylists)[id]) + if (!playlist) return [] let songs = playlist.songs let mode = playlist.mode @@ -187,6 +238,7 @@ export function clearAudioStreams(id: string = "") { // const audioUpdateInterval: number = 100 // ms const audioUpdateInterval: number = 50 // ms let interval: any = null +let isCrossfading: boolean = false export function analyseAudio() { if (interval) return @@ -198,53 +250,30 @@ export function analyseAudio() { let updateAudio: number = 10 interval = setInterval(() => { // get new audio - updateAudio++ - if (updateAudio >= 10) { - updateAudio = 0 - allAudio = Object.entries(get(playingAudio)) - .map(([id, a]: any) => ({ id, ...a })) - .filter((audio) => { - let audioPath = audio.id - if (!audio.audio) return false - - // check if finished - if (!audio.paused && audio.audio.currentTime >= audio.audio.duration) { - if (get(media)[audioPath]?.loop) { - get(playingAudio)[audioPath].audio.currentTime = 0 - get(playingAudio)[audioPath].audio.play() - } else if (get(activePlaylist)?.active === audioPath) { - playingAudio.update((a: any) => { - delete a[audioPath] - return a - }) - - playlistNext(audioPath) - return false - } else { - playingAudio.update((a: any) => { - // a[audioPath].paused = true - delete a[audioPath] - return a - }) - - if (!Object.keys(get(playingAudio)).length) checkNextAfterMedia(audioPath, "audio") - return false - } - } - return audio.paused === false && audio.audio.volume - }) + let playlistPath: string = get(activePlaylist)?.active || "" + if (!isfading && playlistPath && !get(media)[playlistPath]?.loop) { + if (isCrossfading) return - // remove cleared videos - let videos: any[] = get(playingVideos).filter((a) => document.contains(a.video)) - - if (videos.length) { - videos.map((a) => { - if (!a.paused) allAudio.push({ ...a }) - }) + let crossfadeDuration = checkCrossfade() + if (crossfadeDuration) { + isCrossfading = true + setTimeout(() => (isCrossfading = false), crossfadeDuration) + return } + } else { + isCrossfading = false } + updateAudio++ + if (updateAudio >= 10) { + updateAudio = 0 + allAudio = getPlayingAudio() + allAudio.push(...getPlayingVideos()) // only used in output window I guess + } + + allAudio = getPlayingOutputVideos(allAudio) // only used in main window + if (!allAudio.length) { audioChannels.set({ left: 0, right: 0 }) clearInterval(interval) @@ -254,22 +283,133 @@ export function analyseAudio() { return } - // merge audio - let allLefts: number[] = [] - let allRights: number[] = [] - allAudio.forEach((a: any) => { - let aa: any - if (a.channels !== undefined) aa = a.channels - else aa = { left: audioAnalyser(a.analyser.left), right: audioAnalyser(a.analyser.right) } - if (aa.left > 0 || aa.right > 0) { - allLefts.push(aa.left) - allRights.push(aa.right) + mergeAudio(allAudio) + }, audioUpdateInterval) +} + +function mergeAudio(allAudio) { + let allLefts: number[] = [] + let allRights: number[] = [] + + allAudio.forEach((a: any) => { + let channels: any + if (a.channels !== undefined) channels = a.channels + else channels = { left: audioAnalyser(a.analyser.left), right: audioAnalyser(a.analyser.right) } + + if (channels.left > 0 || channels.right > 0) { + allLefts.push(channels.left) + allRights.push(channels.right) + } + }) + + let merged = { left: 0, right: 0 } + if (allLefts.length || allRights.length) merged = { left: getHighestNumber(allLefts), right: getHighestNumber(allRights) } + + audioChannels.set(merged) +} + +const extraMargin = 0.1 // s +function checkCrossfade(): number { + let playlistId = get(activePlaylist)?.id || "" + let playlist = get(audioPlaylists)[playlistId] || {} + let crossfade = Number(playlist.crossfade) || 0 + let activePath = get(activePlaylist)?.active || "" + if (!crossfade || !activePath) return 0 + + let playing = get(playingAudio)[activePath]?.audio + if (!playing) return 0 + + let reachedEnding = playing.currentTime + crossfade + extraMargin >= playing.duration + if (!reachedEnding) return 0 + + playlistNext(activePath, "", crossfade) + return crossfade +} + +function getPlayingAudio() { + return Object.entries(get(playingAudio)) + .map(([id, a]: any) => ({ id, ...a })) + .filter((audio) => { + let audioPath = audio.id + if (!audio.audio) return false + + // check if finished + if (!audio.paused && audio.audio.currentTime >= audio.audio.duration) { + if (get(media)[audioPath]?.loop) { + get(playingAudio)[audioPath].audio.currentTime = 0 + get(playingAudio)[audioPath].audio.play() + } else if (get(activePlaylist)?.active === audioPath) { + playingAudio.update((a: any) => { + delete a[audioPath] + return a + }) + + playlistNext(audioPath) + return false + } else { + playingAudio.update((a: any) => { + if (get(special).clearMediaOnFinish === false) { + // a[audioPath].audio?.pause() + a[audioPath].paused = true + } else { + delete a[audioPath] + } + + return a + }) + + let stillPlaying = Object.values(get(playingAudio)).filter((a) => !a.audio?.paused) + if (!stillPlaying.length) checkNextAfterMedia(audioPath, "audio") + return false + } } + + return audio.paused === false && audio.audio.volume }) - let merged = { left: 0, right: 0 } - if (allLefts.length || allRights.length) merged = { left: getHighestNumber(allLefts), right: getHighestNumber(allRights) } - audioChannels.set(merged) - }, audioUpdateInterval) +} + +function getPlayingVideos() { + // remove cleared videos + let videos: any[] = get(playingVideos).filter((a) => document.contains(a.video)) + if (!videos.length) return [] + + let allAudio: any[] = [] + + videos.map((a) => { + // set volume (video in output window) + let newVolume = get(volume) + if (a.analyser.gainNode) { + let gainedValue = newVolume * (get(gain) || 1) + a.analyser.gainNode.gain.value = gainedValue + } else a.video.volume = newVolume + + if (!a.paused) allAudio.push(a) + }) + + return allAudio +} + +function getPlayingOutputVideos(allAudio) { + let outputVideos: any[] = get(playingVideos).filter((a) => a.location === "output") + if (!outputVideos.length) return allAudio + + outputVideos.map((v) => { + let existing = allAudio.findIndex((a) => a.id === v.id && a.location === "output") + if (existing > -1) { + if (v.paused) { + allAudio.splice(existing, 1) + return + } + + allAudio[existing].channels = v.channels + return + } + + if (v.paused) return + allAudio.push(v) + }) + + return allAudio } // function getAverageNumber(numbers: number[]): number { @@ -286,6 +426,9 @@ export function clearAudio(path: string = "", clearPlaylist: boolean = true) { // turn off any playlist if (clearPlaylist && (!path || get(activePlaylist)?.active === path)) activePlaylist.set(null) + // stop playing metronome + if (clearPlaylist && !path) stopMetronome() + // let clearTime = get(transitionData).audio.duration // TODO: starting audio before previous clear is finished will not start/clear audio const clearTime = get(special).audio_fade_duration ?? 1.5 @@ -301,26 +444,15 @@ export function clearAudio(path: string = "", clearPlaylist: boolean = true) { return a - function clearAudio(path) { + async function clearAudio(path) { if (!a[path].audio) return deleteAudio(path) - fadeAudio(a[path].audio, clearTime) - setTimeout(() => removeAudio(path), clearTime * 1000 * 1.1) + let faded = await fadeAudio(a[path].audio, clearTime) + if (faded) removeAudio(path) } - function removeAudio(path, tries = 0) { - if (tries > 5 || !a[path]?.audio) return deleteAudio(path) - - if (a[path].audio.volume > 0.01) { - setTimeout( - () => { - removeAudio(path, tries + 1) - }, - clearTime ? 1000 : 0 - ) - - return - } + function removeAudio(path) { + if (!a[path]?.audio) return deleteAudio(path) a[path].audio.pause() deleteAudio(path) @@ -347,17 +479,79 @@ export function clearAudio(path: string = "", clearPlaylist: boolean = true) { } } -function fadeAudio(audio, duration = 1) { - let speed = 0.01 - let time = (duration * 1000) / (audio.volume / speed) +// fade out/in when video starts playing +let isfading = false +export function fadeoutAllPlayingAudio() { + stopFading() + isfading = true + + Object.values(get(playingAudio)).forEach(({ audio }) => { + fadeoutAudio(audio) + }) + + async function fadeoutAudio(audio) { + let faded = await fadeAudio(audio, get(special).audio_fade_duration ?? 1.5) + if (faded) { + audio.pause() + // analyseAudio() + } + } +} +export function fadeinAllPlayingAudio() { + if (!isfading) return + stopFading() + + Object.values(get(playingAudio)).forEach(({ audio }) => { + fadeinAudio(audio) + }) + + isfading = false + + async function fadeinAudio(audio) { + audio.play() + await fadeAudio(audio, get(special).audio_fade_duration ?? 1.5, true) + // if (faded) analyseAudio() + } +} + +function stopFading() { + Object.values(currentlyFading).forEach((fadeInterval: any) => { + clearInterval(fadeInterval) + }) +} + +const speed = 0.01 +let currentlyFading: any = {} +async function fadeAudio(audio, duration = 1, increment: boolean = false): Promise { + if (!audio || !duration) return true - // WIP not linear easing + let time = duration * 1000 * speed - let fadeAudio = setInterval(() => { - audio.volume = Math.max(0, Number((audio.volume - speed).toFixed(3))) + // WIP non linear easing - if (audio.volume === 0) clearInterval(fadeAudio) - }, time) + let fadeId = uid() + return new Promise((resolve) => { + currentlyFading[fadeId] = setInterval(() => { + if (increment) { + audio.volume = Math.min(1, Number((audio.volume + speed).toFixed(3))) + if (audio.volume === 1) finished() + } else { + audio.volume = Math.max(0, Number((audio.volume - speed).toFixed(3))) + if (audio.volume === 0) finished() + } + }, time) + + let timedout = setTimeout(() => { + clearInterval(currentlyFading[fadeId]) + resolve(false) + }, duration * 1200) + + function finished() { + clearInterval(currentlyFading[fadeId]) + clearTimeout(timedout) + setTimeout(() => resolve(true), 50) + } + }) } // https://stackoverflow.com/questions/20769261/how-to-get-video-elements-current-level-of-loudness diff --git a/src/frontend/components/helpers/clipboard.ts b/src/frontend/components/helpers/clipboard.ts index 14c161ff..43be2c53 100644 --- a/src/frontend/components/helpers/clipboard.ts +++ b/src/frontend/components/helpers/clipboard.ts @@ -43,7 +43,6 @@ import { variables, videoMarkers, } from "../../stores" -import { newToast } from "../../utils/messages" import { removeSlide } from "../context/menuClick" import { deleteTimer } from "../drawer/timers/timers" import { setCaret } from "../edit/scripts/textStyle" @@ -53,6 +52,7 @@ import { history } from "./history" import { loadShows } from "./setShow" import { _show } from "./shows" import { getFileName, removeExtension } from "./media" +import { newToast } from "../../utils/common" export function copy({ id, data }: any = {}, getData: boolean = true) { let copy: any = { id, data } @@ -111,6 +111,7 @@ export function cut(data: any = {}) { } export function deleteAction({ id, data }, type: string = "delete") { + console.log("DELETE", id, data) if (!deleteActions[id]) return false let deleted: any = deleteActions[id](data, type) diff --git a/src/frontend/components/helpers/drop.ts b/src/frontend/components/helpers/drop.ts index f779ea4d..f7b1412a 100644 --- a/src/frontend/components/helpers/drop.ts +++ b/src/frontend/components/helpers/drop.ts @@ -7,7 +7,7 @@ export type DropAreas = "all_slides" | "slides" | "slide" | "edit" | "shows" | " const areas: { [key in DropAreas | string]: string[] } = { all_slides: ["template"], - slides: ["media", "audio", "overlay", "sound", "screen", "camera", "microphone", "scripture", "trigger", "audio_stream", "show", "midi", "action"], // group + slides: ["media", "audio", "overlay", "sound", "screen", "camera", "microphone", "scripture", "trigger", "audio_stream", "metronome", "show", "midi", "action"], // group // slide: ["overlay", "sound", "camera"], // "media", // projects: ["folder"], project: ["show_drawer", "media", "audio", "player", "scripture"], @@ -36,7 +36,7 @@ export function ondrop(e: any, id: string) { let elem: any = null if (e !== null) { // if (id === "project" || sel.id === "slide" || sel.id === "group" || sel.id === "global_group" || sel.id === "media") elem = e.target.closest(".selectElem") - if (id === "project" || id === "projects" || id === "slides" || id === "all_slides" || id === "navigation") elem = e.target.closest(".selectElem") + if (id === "project" || id === "projects" || id === "slides" || id === "all_slides" || id === "navigation" || id === "templates") elem = e.target.closest(".selectElem") else if (id === "slide") elem = e.target.querySelector(".selectElem") } diff --git a/src/frontend/components/helpers/dropActions.ts b/src/frontend/components/helpers/dropActions.ts index abb2d83a..051e0bde 100644 --- a/src/frontend/components/helpers/dropActions.ts +++ b/src/frontend/components/helpers/dropActions.ts @@ -3,7 +3,24 @@ import { uid } from "uid" import type { Show } from "../../../types/Show" import { ShowObj } from "../../classes/Show" import { changeLayout, changeSlideGroups } from "../../show/slides" -import { activeDrawerTab, activePage, activeProject, activeShow, audioExtensions, audioPlaylists, audioStreams, categories, drawerTabsData, imageExtensions, media, projects, scriptureSettings, showsCache, videoExtensions } from "../../stores" +import { + activeDrawerTab, + activePage, + activeProject, + activeShow, + audioExtensions, + audioPlaylists, + audioStreams, + categories, + drawerTabsData, + imageExtensions, + media, + projects, + scriptureSettings, + showsCache, + templates, + videoExtensions, +} from "../../stores" import { getShortBibleName, getSlides, joinRange } from "../drawer/bible/scripture" import { addItem } from "../edit/scripts/itemHelpers" import { clone, removeDuplicates } from "./array" @@ -203,6 +220,21 @@ export const dropActions: any = { } }, templates: ({ drag, drop }: any) => { + if (drag.id === "files") { + let mediaPath: string = drag.data?.[0]?.path + let templateId: string = drop.data + if (!mediaPath || !templateId) return + + if (!files[drop.id].includes(getExtension(mediaPath))) return + + let templateSettings = get(templates)[templateId]?.settings || {} + let newData = { key: "settings", data: { ...templateSettings, backgroundPath: mediaPath } } + + history({ id: "UPDATE", newData, oldData: { id: templateId }, location: { page: "edit", id: "template_settings", override: templateId } }) + + return + } + if (drag.id !== "slide") return drag.data.forEach(({ index }: any) => { @@ -230,11 +262,13 @@ export const dropActions: any = { // "show", "project" const fileDropExtensions: any = [...get(imageExtensions), ...get(videoExtensions), ...get(audioExtensions)] +const mediaExtensions: any = [...get(imageExtensions), ...get(videoExtensions)] const files: any = { project: fileDropExtensions, slides: fileDropExtensions, slide: fileDropExtensions, + templates: mediaExtensions, } const slideDrop: any = { @@ -263,6 +297,7 @@ const slideDrop: any = { if (drag.id === "files" && drop.index !== undefined) center = true if (center) { + if (!data[0]) return history.id = "showMedia" if (drop.trigger?.includes("end")) drop.index!-- @@ -333,7 +368,7 @@ const slideDrop: any = { }, slide: ({ drag, drop }: any, history: any) => { history.id = "slide" - let ref: any[] = _show().layouts("active").ref()[0] + let ref: any[] = _show().layouts("active").ref()[0] || [] let slides = _show().get().slides let oldLayout = _show().layouts("active").get()[0].slides @@ -475,7 +510,7 @@ const slideDrop: any = { delete slide.id slides[id] = slide - let parent = ref[newIndex - 1] + let parent = ref[newIndex - 1] || { index: -1 } if (parent.type === "child") parent = parent.parent layout = addToPos(layout, [{ id }], parent.index + 1) @@ -488,11 +523,11 @@ const slideDrop: any = { trigger: ({ drag, drop }: any, history: any) => { history.id = "SHOW_LAYOUT" - let ref: any = _show().layouts("active").ref()[0][drop.index!] - let data: any = ref.data.actions || {} - data.trigger = drag.data[0].id + let data = drag.data[0] + let actions = createSlideAction("start_trigger", drop.index, data) + if (!actions) return - history.newData = { key: "actions", data, indexes: [drop.index] } + history.newData = { key: "actions", data: actions, indexes: [drop.index] } return history }, audio_stream: ({ drag, drop }: any, history: any) => { @@ -502,14 +537,25 @@ const slideDrop: any = { let stream = get(audioStreams)[streamId] if (!stream) return - let ref: any = _show().layouts("active").ref()[0][drop.index!] - let data: any = ref.data.actions || {} - data.audioStream = { id: streamId, ...stream } + let data = { id: streamId, ...stream } + let actions = createSlideAction("start_audio_stream", drop.index, data) + if (!actions) return - history.newData = { key: "actions", data, indexes: [drop.index] } + history.newData = { key: "actions", data: actions, indexes: [drop.index] } + return history + }, + metronome: ({ drag, drop }: any, history: any) => { + history.id = "SHOW_LAYOUT" + + let data = drag.data[0] + let actions = createSlideAction("start_metronome", drop.index, data, true) + if (!actions) return + + history.newData = { key: "actions", data: actions, indexes: [drop.index] } return history }, midi: ({ drag, drop }: any, history: any) => { + // WIP not in use: history.id = "SHOW_LAYOUT" let ref: any = _show().layouts("active").ref()[0][drop.index!] @@ -545,6 +591,24 @@ const slideDrop: any = { // HELPERS +function createSlideAction(triggerId: string, slideIndex: number, data: any, removeExisting: boolean = false) { + let ref: any = _show().layouts("active").ref()[0][slideIndex] + if (!ref) return + let actions: any = ref.data?.actions || {} + let slideActions: any[] = actions.slideActions || [] + + if (removeExisting) { + let existingIndex = slideActions.findIndex((a) => a.triggers?.[0] === triggerId) + if (existingIndex > -1) slideActions.splice(existingIndex, 1) + } + + let actionValues = { [triggerId]: data } + slideActions.push({ id: uid(), triggers: [triggerId], actionValues }) + + actions.slideActions = slideActions + return actions +} + // WIP duplicate of ScriptureInfo.svelte createSlides() function createScriptureShow(drag) { let bibles = drag.data[0]?.bibles diff --git a/src/frontend/components/helpers/historyActions.ts b/src/frontend/components/helpers/historyActions.ts index 3b7af871..d88bd414 100644 --- a/src/frontend/components/helpers/historyActions.ts +++ b/src/frontend/components/helpers/historyActions.ts @@ -2,16 +2,16 @@ import { get } from "svelte/store" import { uid } from "uid" import type { Slide } from "../../../types/Show" import { removeItemValues } from "../../show/slides" -import { activeEdit, activePage, activePopup, activeShow, alertMessage, cachedShowsData, deletedShows, driveData, notFound, refreshEditSlide, renamedShows, shows, showsCache, templates } from "../../stores" +import { activeEdit, activePage, activePopup, activeShow, alertMessage, cachedShowsData, deletedShows, driveData, groups, notFound, refreshEditSlide, renamedShows, shows, showsCache, templates } from "../../stores" import { save } from "../../utils/save" import { EMPTY_SHOW_SLIDE } from "../../values/empty" -import { getItemText } from "../edit/scripts/textStyle" import { clone, keysToID } from "./array" import { _updaters } from "./historyHelpers" import { addToPos } from "./mover" -import { getItemsCountByType, mergeWithTemplate } from "./output" +import { getItemsCountByType, isEmptyOrSpecial, mergeWithTemplate, updateLayoutsFromTemplate, updateSlideFromTemplate } from "./output" import { loadShows } from "./setShow" import { _show } from "./shows" +import { customActionActivation } from "../actions/actions" // TODO: move history switch to actions @@ -20,7 +20,6 @@ export const historyActions = ({ obj, undo = null }: any) => { let initializing: boolean = undo === null if (obj) { - console.log(obj, undo) data = obj.newData || {} // if (initializing && !obj.oldData) obj.oldData = clone(obj.newData) // WIP } @@ -52,6 +51,7 @@ export const historyActions = ({ obj, undo = null }: any) => { id = obj.oldData?.id || uid() if (keys && !key) id = "keys" + if (initializing && obj.location.id === "show") customActionActivation("show_created") if (initializing && empty && updater.initialize) data.data = updater.initialize(data.data) if (data.replace) { @@ -721,41 +721,60 @@ export const historyActions = ({ obj, undo = null }: any) => { Object.entries(slides).forEach(([id, slide]: any) => { if ((slideId && slideId !== id) || !slide) return + // show template let slideTemplate = template - if (slide.settings?.template) slideTemplate = clone(get(templates)[slide.settings.template]) || template + let isGlobalTemplate = true + // slide template + if (slide.settings?.template) { + slideTemplate = clone(get(templates)[slide.settings.template]) || template + isGlobalTemplate = false + } else { + // group template + let isChild = slide.group === null + let globalGroup = slide.globalGroup + if (isChild) { + let parent = Object.values(a[data.remember.showId].slides).find((a) => a.children?.includes(id)) + globalGroup = parent?.globalGroup + } + if (globalGroup && get(groups)[globalGroup]?.template) { + slideTemplate = clone(get(templates)[get(groups)[globalGroup]?.template]) || template + isGlobalTemplate = false + } + } + if (!slideTemplate?.items?.length) return // roll items around if (createItems && !slide.settings?.template) slide.items = [...slide.items.slice(1), slide.items[0]].filter((a) => a) - let newItems = mergeWithTemplate(slide.items, slideTemplate.items, slide.settings?.template ?? createItems, obj.save !== false) + let changeOverflowItems = slide.settings?.template || createItems + let newItems = mergeWithTemplate(slide.items, slideTemplate.items, changeOverflowItems, obj.save !== false) // remove items if not in template (and textbox is empty) - let templateItemCount = getItemsCountByType(slideTemplate.items) - let slideItemCount = getItemsCountByType(newItems) - newItems = newItems.filter((a) => { - let type = a.type || "text" - if (templateItemCount[type] - slideItemCount[type] >= 0) return true - if (type === "text" && getItemText(a).length) return true - - // remove item - slideItemCount[type]-- - return false - }) - // // remove items if textbox is empty and not in template - // let templateTextboxes = slideTemplate.items.reduce((count, item) => (count += (item.type || "text") === "text" ? 1 : 0), 0) - // let slideTextboxes = newItems.reduce((count, item) => (count += (item.type || "text") === "text" ? 1 : 0), 0) - // newItems = newItems.filter((a) => { - // if ((a.type || "text") !== "text") return true - // if (templateTextboxes - slideTextboxes >= 0) return true - // if (getItemText(a).length) return true - - // // remove item - // slideTextboxes-- - // return false - // }) + if (changeOverflowItems) { + let templateItemCount = getItemsCountByType(slideTemplate.items) + let slideItemCount = getItemsCountByType(newItems) + newItems = newItems.filter((a) => { + let type = a.type || "text" + if (templateItemCount[type] - slideItemCount[type] >= 0) return true + if (type === "text" && !isEmptyOrSpecial(a)) return true + + // remove item + slideItemCount[type]-- + return false + }) + } a[data.remember.showId].slides[id].items = clone(newItems) + + if (!isGlobalTemplate) return + + // set custom values + let isFirst = !!Object.values(a[data.remember.showId].layouts).find((layout) => layout.slides[0]?.id === id) + a[data.remember.showId].slides[id] = updateSlideFromTemplate(a[data.remember.showId].slides[id], slideTemplate, isFirst, changeOverflowItems) + let newLayoutData = updateLayoutsFromTemplate(a[data.remember.showId].layouts, a[data.remember.showId].media, slideTemplate, changeOverflowItems) + a[data.remember.showId].layouts = newLayoutData.layouts + a[data.remember.showId].media = newLayoutData.media }) return a @@ -899,7 +918,7 @@ export const historyActions = ({ obj, undo = null }: any) => { console.error(obj.id, "HISTORY ERROR:", msg) } - if (obj) console.info(obj.id, "HISTORY " + (initializing ? "INIT" : undo ? "UNDO" : "REDO") + ":", clone(obj)) + if (obj) console.info("HISTORY " + (initializing ? "INIT" : undo ? "UNDO" : "REDO") + ` [${obj.id}]:`, clone(obj)) return actions } diff --git a/src/frontend/components/helpers/historyHelpers.ts b/src/frontend/components/helpers/historyHelpers.ts index 79132934..f4bedeea 100644 --- a/src/frontend/components/helpers/historyHelpers.ts +++ b/src/frontend/components/helpers/historyHelpers.ts @@ -294,6 +294,7 @@ export const _updaters = { template_name: { store: templates, empty: "" }, template_color: { store: templates, empty: null }, template_category: { store: templates, empty: null }, + template_settings: { store: templates, empty: {} }, player_video: { store: playerVideos, empty: EMPTY_PLAYER_VIDEO }, @@ -434,7 +435,7 @@ export const _updaters = { updateThemeValues(get(themes)[id]) }, 100) - if (!initializing) return + if (!initializing || data.key) return activeRename.set("theme_" + id) }, deselect: (id: string, data: any) => { diff --git a/src/frontend/components/helpers/media.ts b/src/frontend/components/helpers/media.ts index 797d946c..c81ee786 100644 --- a/src/frontend/components/helpers/media.ts +++ b/src/frontend/components/helpers/media.ts @@ -3,9 +3,12 @@ import type { ShowType } from "../../../types/Show" // ----- FreeShow ----- // This is for media/file functions -import type { Styles } from "../../../types/Settings" -import { audioExtensions, imageExtensions, mediaCache, videoExtensions } from "../../stores" +import { MAIN } from "../../../types/Channels" import type { MediaStyle } from "../../../types/Main" +import type { Styles } from "../../../types/Settings" +import { audioExtensions, imageExtensions, loadedMediaThumbnails, tempPath, videoExtensions } from "../../stores" +import { wait, waitUntilValueIsDefined } from "../../utils/common" +import { awaitRequest, send } from "../../utils/request" export function getExtension(path: string): string { if (!path) return "" @@ -38,7 +41,7 @@ export function getFileName(path: string): string { return path } -let pathJoiner = "/" +let pathJoiner = "" export function splitPath(path: string): string[] { if (!path) return [] if (path.indexOf("\\") > -1) pathJoiner = "\\" @@ -47,6 +50,7 @@ export function splitPath(path: string): string[] { } export function joinPath(path: string[]): string { + if (!pathJoiner) splitPath(path[0]) return path.join(pathJoiner) } @@ -63,12 +67,12 @@ export function encodeFilePath(path: string): string { } // convert to base64 -export async function toDataURL(url: string) { +async function toDataURL(url: string): Promise { return new Promise((resolve: any) => { var xhr = new XMLHttpRequest() xhr.onload = () => { var reader = new FileReader() - reader.onloadend = () => resolve(reader.result) + reader.onloadend = () => resolve(reader.result?.toString()) reader.readAsDataURL(xhr.response) } xhr.open("GET", url) @@ -96,15 +100,6 @@ export function checkMedia(src: string) { elem.onload = () => resolve("true") } - elem.onerror = () => { - // remove cached thumbnail - mediaCache.update((a) => { - delete a[src] - return a - }) - - resolve("false") - } elem.src = src }) } @@ -131,3 +126,129 @@ export function getMediaStyle(mediaObj: MediaStyle, currentStyle: Styles) { return mediaStyle } + +export const mediaSize = { + big: 900, // stage & editor + slideSize: 500, // slide + remote + drawerSize: 250, // drawer media + small: 100, // show tools +} + +export async function loadThumbnail(input: string, size: number) { + if (!input) return "" + + let loadedPath = get(loadedMediaThumbnails)[getThumbnailId({ input, size })] + if (loadedPath) return loadedPath + + let data = await awaitRequest(MAIN, "GET_THUMBNAIL", { input, size }) + if (!data) return "" + + thumbnailLoaded(data) + return data.output as string +} + +export function getThumbnailPath(input: string, size: number) { + if (!input) return "" + + let loadedPath = get(loadedMediaThumbnails)[getThumbnailId({ input, size })] + if (loadedPath) return loadedPath + + return joinPath([get(tempPath), getFileName(hashCode(input), size)]) + + function getFileName(path, size) { + return `${path}-${size}.jpg` + } +} + +// same as electron/thumbnails.ts +function hashCode(str: string) { + if (!str) return "" + let hash = 0 + + for (let i = 0; i < str.length; i++) { + let chr = str.charCodeAt(i) + hash = (hash << 5) - hash + chr // bit shift + hash |= 0 // convert to 32bit integer + } + + if (hash < 0) return "i" + hash.toString().slice(1) + return "a" + hash.toString() +} + +export function thumbnailLoaded(data: { input: string; output: string; size: number }) { + loadedMediaThumbnails.update((a) => { + a[getThumbnailId(data)] = data.output + return a + }) +} + +function getThumbnailId(data: any) { + return `${data.input}-${data.size}` +} + +// convert path to base64 +export async function getBase64Path(path: string, size: number = mediaSize.big) { + let thumbnailPath = await loadThumbnail(path, size) + // wait if thumnail is not generated yet + await wait(200) + let base64Path = await toDataURL(thumbnailPath) + + // "data:image/png;base64," + + return base64Path +} + +// CACHE + +const jpegQuality = 90 // 0-100 +export function captureCanvas(data: any) { + let canvas = document.createElement("canvas") + + let isImage: boolean = get(imageExtensions).includes(data.extension) + let mediaElem: any = document.createElement(isImage ? "img" : "video") + mediaElem.src = data.input + + mediaElem.addEventListener(isImage ? "load" : "loadeddata", async () => { + if (!isImage) mediaElem.currentTime = mediaElem.duration * (data.seek ?? 0.5) + + let mediaSize = isImage ? { width: mediaElem.naturalWidth, height: mediaElem.naturalHeight } : { width: mediaElem.videoWidth, height: mediaElem.videoHeight } + let newSize = getNewSize(mediaSize, data.size || {}) + canvas.width = newSize.width + canvas.height = newSize.height + + // wait until loaded + let hasLoaded = await waitUntilValueIsDefined(() => (isImage ? mediaElem.complete : mediaElem.readyState === 4), 20) + if (!hasLoaded) return + + console.log(data.input, hasLoaded) + + await wait(50) + captureCanvas(mediaElem, mediaSize) + }) + + async function captureCanvas(media, mediaSize) { + let ctx = canvas.getContext("2d") + if (!ctx) return + + ctx.drawImage(media, 0, 0, mediaSize.width, mediaSize.height, 0, 0, canvas.width, canvas.height) + await wait(50) + let dataURL = canvas.toDataURL("image/jpeg", jpegQuality) + + console.log(dataURL) + + send(MAIN, ["SAVE_IMAGE"], { path: data.output, base64: dataURL }) + } +} + +function getNewSize(contentSize: { width: number; height: number }, newSize: { width?: number; height?: number }) { + if (!contentSize.width) contentSize.width = 1920 + if (!contentSize.height) contentSize.height = 1080 + + const ratio = contentSize.width / contentSize.height + + let width = newSize.width + let height = newSize.height + if (!width) width = height ? Math.floor(height * ratio) : contentSize.width + if (!height) height = newSize.width ? Math.floor(width / ratio) : contentSize.height + + return { width, height } +} diff --git a/src/frontend/components/helpers/output.ts b/src/frontend/components/helpers/output.ts index 2b5ec9c0..af62ab7d 100644 --- a/src/frontend/components/helpers/output.ts +++ b/src/frontend/components/helpers/output.ts @@ -3,20 +3,24 @@ import { uid } from "uid" import { OUTPUT } from "../../../types/Channels" import type { Output } from "../../../types/Output" import type { Resolution, Styles } from "../../../types/Settings" -import type { Item, OutSlide, Show, Transition } from "../../../types/Show" -import { currentOutputSettings, lockedOverlays, outputDisplay, outputs, overlays, playingVideos, showsCache, special, styles, templates, theme, themes, transitionData } from "../../stores" +import type { Item, Layout, Media, OutSlide, Show, Slide, Template, TemplateSettings, Transition } from "../../../types/Show" +import { currentOutputSettings, lockedOverlays, outputDisplay, outputs, overlays, playingVideos, showsCache, special, styles, templates, theme, themes, transitionData, videoExtensions } from "../../stores" import { send } from "../../utils/request" -import { getSlideText } from "../edit/scripts/textStyle" +import { sendBackgroundToStage } from "../../utils/stageTalk" +import { getItemText, getSlideText } from "../edit/scripts/textStyle" import { clone, removeDuplicates } from "./array" -import { clearBackground, replaceDynamicValues } from "./showActions" +import { getExtension, getFileName, removeExtension } from "./media" +import { replaceDynamicValues } from "./showActions" import { _show } from "./shows" +import { fadeinAllPlayingAudio, fadeoutAllPlayingAudio } from "./audio" +import { customActionActivation } from "../actions/actions" export function displayOutputs(e: any = {}, auto: boolean = false) { let enabledOutputs: any[] = getActiveOutputs(get(outputs), false) enabledOutputs.forEach((id) => { let output: any = { id, ...get(outputs)[id] } let autoPosition = enabledOutputs.length === 1 - send(OUTPUT, ["DISPLAY"], { enabled: !get(outputDisplay), output, force: e.ctrlKey || e.metaKey, auto, autoPosition }) + send(OUTPUT, ["DISPLAY"], { enabled: !get(outputDisplay), output, force: output.allowMainScreen || e.ctrlKey || e.metaKey, auto, autoPosition }) }) } @@ -36,19 +40,7 @@ export function setOutput(key: string, data: any, toggle: boolean = false, outpu if (!output.out) a[id].out = {} if (!output.out?.[key]) a[id].out[key] = key === "overlays" ? [] : null - if (key === "background" && data) { - // mute videos in the other output windows if more than one - data.muted = data.muted || false - if (outs.length > 1 && i > 0) data.muted = true - - let videoData: any = { muted: data.muted, loop: data.loop || false } - - setTimeout(() => { - // WIP data is sent directly in output, so this is probably not needed - send(OUTPUT, ["DATA"], { [id]: videoData }) - if (data.startAt !== undefined) send(OUTPUT, ["TIME"], { [id]: data.startAt || 0 }) - }, 100) - } + if (key === "background") data = changeOutputBackground(data, { outs, output, id, i }) let outData = a[id].out?.[key] || null if (key === "overlays" && data.length) { @@ -68,6 +60,54 @@ export function setOutput(key: string, data: any, toggle: boolean = false, outpu }) } +function changeOutputBackground(data, { outs, output, id, i }) { + setTimeout(() => { + // update stage background if any + sendBackgroundToStage(id) + }, 100) + + let previousWasVideo: boolean = get(videoExtensions).includes(getExtension(output.out?.background?.path)) + + if (data === null) { + fadeinAllPlayingAudio() + if (previousWasVideo) videoEnding() + + return data + } + + // mute videos in the other output windows if more than one + data.muted = data.muted || false + if (outs.length > 1 && i > 0) data.muted = true + + let videoData: any = { muted: data.muted, loop: data.loop || false } + + let muteAudio = get(special).muteAudioWhenVideoPlays + let isVideo = get(videoExtensions).includes(getExtension(data.path)) + if (!data.muted && muteAudio && isVideo) fadeoutAllPlayingAudio() + else fadeinAllPlayingAudio() + + if (isVideo) videoStarting() + else if (previousWasVideo) videoEnding() + + // wait for video receiver to change + setTimeout(() => { + // data is sent directly in output as well ?? + send(OUTPUT, ["DATA"], { [id]: videoData }) + if (data.startAt !== undefined) send(OUTPUT, ["TIME"], { [id]: data.startAt || 0 }) + }, 600) + + return data +} + +function videoEnding() { + setTimeout(() => { + customActionActivation("video_end") + }) +} +function videoStarting() { + customActionActivation("video_start") +} + export function getActiveOutputs(updater: any = get(outputs), hasToBeActive: boolean = true, removeKeyOutput: boolean = false, removeStageOutput: boolean = false) { let sortedOutputs: any[] = Object.entries(updater || {}) .map(([id, a]: any) => ({ id, ...a })) @@ -250,20 +290,14 @@ export function deleteOutput(outputId: string) { }) } -// WIP improve this export async function clearPlayingVideo(clearOutput: string = "") { - // videoData.paused = true - if (clearOutput) clearBackground(clearOutput) // , false, clearOutput - let mediaTransition: Transition = getCurrentMediaTransition() - let duration = mediaTransition?.duration || 0 + let duration = (mediaTransition?.duration || 0) + 200 if (!clearOutput) duration /= 2.4 // a little less than half the time return new Promise((resolve) => { setTimeout(() => { - // if (!videoData.paused) return - // remove from playing playingVideos.update((a) => { let existing = -1 @@ -374,6 +408,76 @@ export function mergeWithTemplate(slideItems: Item[], templateItems: Item[], add return newSlideItems } +export function updateSlideFromTemplate(slide: Slide, template: Template, isFirst: boolean = false, removeOverflow: boolean = false) { + let settings = template.settings || {} + + if (settings.resolution || slide.settings.resolution) slide.settings.resolution = getResolution(settings.resolution) + if (isFirst && (settings.firstSlideTemplate || removeOverflow)) slide.settings.template = settings.firstSlideTemplate || "" + if (settings.backgroundColor || slide.settings.color) slide.settings.color = settings.backgroundColor || "" + + // add overlay items to slide items + if (removeOverflow && settings.overlayId) { + let overlayItems = get(overlays)[settings.overlayId]?.items || [] + slide.items.push(...overlayItems) + } + + return slide +} + +export function updateLayoutsFromTemplate(layouts: { [key: string]: Layout }, media: { [key: string]: Media }, template: Template, removeOverflow: boolean = false) { + // only alter layout slides if clicking on the template + if (!removeOverflow) return { layouts, media } + + let settings = template.settings || {} + + let bgId = "" + if (settings.backgroundPath) { + // find existing + let existingId = Object.keys(media).find((id) => (media[id].path || media[id].id) === id) + bgId = existingId || uid() + if (!existingId) media[bgId] = { path: settings.backgroundPath, name: removeExtension(getFileName(settings.backgroundPath)) } + } + + Object.keys(layouts).forEach((layoutId) => { + let slides = layouts[layoutId].slides + slides.forEach((slide, i) => { + if (i === 0 && settings.backgroundPath) slide.background = bgId + + if (settings.actions?.length) { + if (!slide.actions) slide.actions = {} + + // remove existing + let newSlideActions: any[] = [] + slide.actions.slideActions?.forEach((action) => { + if (settings.actions?.find((a) => a.id === action.id || a.triggers?.[0] === action.triggers?.[0])) return + newSlideActions.push(action) + }) + + slide.actions.slideActions = [...newSlideActions, ...settings.actions] + } + }) + + layouts[layoutId].slides = slides + }) + + return { layouts, media } +} + +function getSlideItemsFromTemplate(templateSettings: TemplateSettings) { + let newItems: Item[] = [] + + // these are set by the output style: resolution, backgroundColor, backgroundPath + // this is not relevant: firstSlideTemplate + + // add overlay items + if (templateSettings.overlayId) { + let overlayItems = get(overlays)[templateSettings.overlayId]?.items || [] + newItems.push(...overlayItems) + } + + return newItems +} + function removeTextValue(items: Item[]) { items.forEach((item) => { if (!item.lines) return @@ -389,7 +493,15 @@ export function getTemplateText(value) { return "" } -function sortItemsByType(items: Item[]) { +export function isEmptyOrSpecial(item: Item) { + let text = getItemText(item) + if (!text.length) return true + if (getTemplateText(text)) return true + + return false +} + +export function sortItemsByType(items: Item[]) { let sortedItems: { [key: string]: Item[] } = {} items.forEach((item) => { @@ -445,11 +557,18 @@ export function getOutputTransitions(slideData: any, transitionData: any, disabl return clone(transitions) } -export function setTemplateStyle(outSlide: any, templateId: string | undefined, items: Item[]) { - let slideItems = outSlide?.id === "temp" ? outSlide.tempItems : items - let templateItems = get(templates)[templateId || ""]?.items || [] +export function setTemplateStyle(outSlide: any, currentStyle: any, items: Item[]) { + let isScripture = outSlide?.id === "temp" + let slideItems = isScripture ? outSlide.tempItems : items + + let templateId = currentStyle[`template${isScripture ? "Scripture" : ""}`] + let template = get(templates)[templateId || ""] || {} + let templateItems = template.items || [] + + let newItems = mergeWithTemplate(slideItems, templateItems, true) + newItems.push(...getSlideItemsFromTemplate(template.settings || {})) - return mergeWithTemplate(slideItems, templateItems, true) + return newItems } export function getOutputLines(outSlide: any, styleLines: any = 0) { diff --git a/src/frontend/components/helpers/setShow.ts b/src/frontend/components/helpers/setShow.ts index 70db9ec2..34b52b29 100644 --- a/src/frontend/components/helpers/setShow.ts +++ b/src/frontend/components/helpers/setShow.ts @@ -54,7 +54,7 @@ export function setShow(id: string, value: "delete" | Show): Show { return a }) - console.log("SHOW UPDATED: ", id, value) + console.info("SHOW UPDATED: ", id, value) if (value && value !== "delete") { cachedShowsData.update((a) => { @@ -87,11 +87,11 @@ export async function loadShows(s: string[]) { }) // resolve("not_found") } else if (!get(showsCache)[id]) { - console.log("LOAD SHOWS:", s) window.api.send(SHOW, { path: get(showsPath), name: get(shows)[id].name, id }) } else count++ // } else resolve("already_loaded") }) + if (s.length - count) console.info(`LOADING ${s.length - count} SHOW(S)`) // RECEIVE let listenerId = uid() diff --git a/src/frontend/components/helpers/show.ts b/src/frontend/components/helpers/show.ts index dd3a4a51..f22763b2 100644 --- a/src/frontend/components/helpers/show.ts +++ b/src/frontend/components/helpers/show.ts @@ -29,7 +29,6 @@ export function getLabelId(label: string, replaceNumbers: boolean = true) { label = label .toLowerCase() .replace(/x[0-9]/g, "") // x0-9 - .replace(/[0-9]/g, "") // 0-9 .replace(/[[\]]/g, "") // [] .replace(/['":]/g, "") // '": .trim() diff --git a/src/frontend/components/helpers/showActions.ts b/src/frontend/components/helpers/showActions.ts index db0b62fa..e6b95ac9 100644 --- a/src/frontend/components/helpers/showActions.ts +++ b/src/frontend/components/helpers/showActions.ts @@ -3,6 +3,7 @@ import { MAIN, OUTPUT } from "../../../types/Channels" import type { OutSlide, Slide } from "../../../types/Show" import { send } from "../../utils/request" import { runAction } from "../actions/actions" +import type { API_output_style } from "../actions/api" import { playPauseGlobal } from "../drawer/timers/timers" import { activeEdit, @@ -19,11 +20,13 @@ import { outputs, overlays, playingAudio, + playingMetronome, projects, selected, showsCache, slideTimers, styles, + templates, timers, triggers, videosData, @@ -32,12 +35,12 @@ import { import { clone } from "./array" import { clearAudio, playAudio, startMicrophone } from "./audio" import { getExtension, getFileName, getMediaStyle, getMediaType, removeExtension } from "./media" -import { getActiveOutputs, isOutCleared, refreshOut, setOutput } from "./output" +import { clearPlayingVideo, getActiveOutputs, isOutCleared, refreshOut, setOutput } from "./output" import { loadShows } from "./setShow" import { initializeMetadata } from "./show" import { _show } from "./shows" import { stopTimers } from "./timerTick" -import type { API_output_style } from "../actions/api" +import { addZero } from "./time" const getProjectIndex: any = { next: (index: number | null, shows: any) => { @@ -375,7 +378,7 @@ function randomNumber(end: number) { return Math.floor(Math.random() * end) } -export function updateOut(showId: string, index: number, layout: any, extra: boolean = true, outputId: string | null = null) { +export function updateOut(showId: string, index: number, layout: any, extra: boolean = true, outputId: string | null = null, actionTimeout: number = 10) { if (get(activePage) !== "edit") activeEdit.set({ slide: index, items: [] }) _show(showId).set({ key: "timestamps.used", value: new Date().getTime() }) @@ -515,7 +518,7 @@ export function updateOut(showId: string, index: number, layout: any, extra: boo // startShow is at the top if (data.actions.trigger) activateTrigger(data.actions.trigger) if (data.actions.audioStream) startAudioStream(data.actions.audioStream) - if (data.actions.sendMidi) sendMidi(_show(showId).get("midi")[data.actions.sendMidi]) + // if (data.actions.sendMidi) sendMidi(_show(showId).get("midi")[data.actions.sendMidi]) // if (data.actions.nextAfterMedia) // go to next when video/audio is finished if (data.actions.outputStyle) changeOutputStyle(data.actions) if (data.actions.startTimer) playSlideTimers({ showId: showId, slideId: layout[index].id, overlayIds: data.overlays }) @@ -525,8 +528,8 @@ export function updateOut(showId: string, index: number, layout: any, extra: boo // let values update setTimeout(() => { playSlideActions(data.actions.slideActions, outputIds, index) - }) - } + }, actionTimeout) + } else playOutputStyleTemplateActions(outputIds) } const runPerOutput = ["clear_background", "clear_overlays"] @@ -546,6 +549,24 @@ function playSlideActions(actions: any[], outputIds: string[] = [], slideIndex: } actions.forEach((a) => runAction(a, { slideIndex })) + + playOutputStyleTemplateActions(outputIds) +} + +// play any output style template actions +function playOutputStyleTemplateActions(outputIds: string[]) { + outputIds.forEach((outputId) => { + let outputStyleId = get(outputs)[outputId]?.style || "" + if (!outputStyleId) return + + let styleTemplateId = get(styles)[outputStyleId]?.template || "" + if (!styleTemplateId) return + + let templateSettings = get(templates)[styleTemplateId]?.settings?.actions || [] + if (!templateSettings?.length) return + + templateSettings?.forEach((action) => runAction(action)) + }) } export async function startShow(showId: string) { @@ -559,8 +580,11 @@ export async function startShow(showId: string) { // slideClick() - Slides.svelte let slideRef: any = _show(showId).layouts("active").ref()[0] - updateOut(showId, 0, slideRef) + if (!slideRef[0]) return + setOutput("slide", { id: showId, layout: activeLayout, index: 0, line: 0 }) + // timeout has to be 1200 to let output data update properly (in case slide has special actions) + updateOut(showId, 0, slideRef, true, null, 1200) } export function changeOutputStyle({ outputStyle, styleOutputs }: API_output_style) { @@ -643,13 +667,14 @@ export function checkNextAfterMedia(endedId: string, type: "media" | "audio" | " // check that current slide has the ended media! if (type === "media" || type === "audio") { let showMedia = _show(slideOut.id).media().get() - let currentMediaId = showMedia.find((a) => a.path === endedId)?.key + // find all matching paths because some slides with same background might have different media ids + let allMediaIds = showMedia.filter((a) => a.path === endedId).map((a) => a.key) // don't go to next if current slide don't has outputted media if (type === "media") { - if (layoutSlide.data?.background !== currentMediaId) return false + if (!allMediaIds.includes(layoutSlide.data?.background)) return false } else if (type === "audio") { - if (!layoutSlide.data?.audio?.find((id) => id === currentMediaId)) return false + if (!layoutSlide.data?.audio?.find((id) => allMediaIds.includes(id))) return false } } else if (type === "timer") { let slide = _show(slideOut.id).get("slides")[layoutSlide.id] @@ -673,11 +698,9 @@ export function playSlideTimers({ showId = "active", slideId = "", overlayIds = let layoutRef = _show("active").layouts([outputRef.layout]).ref()[0] slideId = layoutRef[outputRef.index]?.id || "" } - console.log(slideId, get(outputs)[getActiveOutputs()[0]]) let showSlides: any = _show(showId).get("slides") || {} let slide = showSlides[slideId] - console.log(showSlides, slide) if (!slide) return // find all timers in current slide & any overlay placed on the slide @@ -695,20 +718,22 @@ export function sendMidi(data: any) { } export function clearBackground(outputId: string = "") { - // clearVideo() - setOutput("background", null, false, outputId) - // clearPlayingVideo() + let outputIds: string[] = outputId ? [outputId] : getActiveOutputs() - if (!outputId) return + outputIds.forEach((outputId) => { + // clearVideo() + setOutput("background", null, false, outputId) + clearPlayingVideo(outputId) - // WIP this does not clear time properly - videosData.update((a) => { - delete a[outputId] - return a - }) - videosTime.update((a) => { - delete a[outputId] - return a + // WIP this does not clear time properly + videosData.update((a) => { + delete a[outputId] + return a + }) + videosTime.update((a) => { + delete a[outputId] + return a + }) }) } @@ -717,10 +742,14 @@ export function clearSlide() { } export function clearOverlays(outputId: string = "") { - outputId = outputId || getActiveOutputs()[0] - let outOverlays: string[] = get(outputs)[outputId]?.out?.overlays || [] - outOverlays = outOverlays.filter((id) => get(overlays)[id]?.locked) - setOutput("overlays", outOverlays) + let outputIds: string[] = outputId ? [outputId] : getActiveOutputs() + + outputIds.forEach((outputId) => { + let outOverlays: string[] = get(outputs)[outputId]?.out?.overlays || [] + outOverlays = outOverlays.filter((id) => get(overlays)[id]?.locked) + setOutput("overlays", outOverlays, false, outputId) + }) + lockedOverlays.set([]) } @@ -730,7 +759,8 @@ export function clearAll(button: boolean = false) { if (get(outLocked)) return if (!button && (get(activePopup) || get(selected).id || get(activeEdit).items.length)) return - let allCleared = isOutCleared(null) && !Object.keys(get(playingAudio)).length + let audioCleared = !Object.keys(get(playingAudio)).length && !get(playingMetronome) + let allCleared = isOutCleared(null) && audioCleared if (allCleared) return // TODO: audio @@ -791,7 +821,7 @@ export function getDynamicIds() { return [...mainValues, ...metaValues] } -export function replaceDynamicValues(text: string, { showId, layoutId, slideIndex }: any) { +export function replaceDynamicValues(text: string, { showId, layoutId, slideIndex }: any, _updater: number = 0) { let show = _show(showId).get() if (!show) return text @@ -817,12 +847,21 @@ export function replaceDynamicValues(text: string, { showId, layoutId, slideInde } const dynamicValues = { + // time + time_date: () => addZero(new Date().getDate()), + time_month: () => addZero(new Date().getMonth() + 1), + time_year: () => new Date().getFullYear(), + time_hours: () => addZero(new Date().getHours()), + time_minutes: () => addZero(new Date().getMinutes()), + time_seconds: () => addZero(new Date().getSeconds()), + + // show show_name: ({ show }) => show.name || "", layout_slides: ({ ref }) => ref.length, layout_notes: ({ layout }) => layout.notes || "", - slide_group: ({ show, ref, slideIndex }) => show.slides[ref[slideIndex].id].group || "", + slide_group: ({ show, ref, slideIndex }) => show.slides[ref[slideIndex]?.id]?.group || "", slide_number: ({ slideIndex }) => Number(slideIndex || 0) + 1, - slide_notes: ({ show, ref, slideIndex }) => show.slides[ref[slideIndex].id].notes || "", + slide_notes: ({ show, ref, slideIndex }) => show.slides[ref[slideIndex]?.id]?.notes || "", } diff --git a/src/frontend/components/helpers/timerTick.ts b/src/frontend/components/helpers/timerTick.ts index 69d2fe2c..4085d280 100644 --- a/src/frontend/components/helpers/timerTick.ts +++ b/src/frontend/components/helpers/timerTick.ts @@ -1,20 +1,22 @@ import { get } from "svelte/store" import type { Event } from "../../../types/Calendar" import { OUTPUT, STAGE } from "../../../types/Channels" -import { activeTimers, currentWindow, dictionary, events, nextShowEventPaused, nextShowEventStart, shows } from "../../stores" -import { newToast } from "../../utils/messages" +import { activeTimers, currentWindow, dictionary, events, nextActionEventPaused, nextActionEventStart } from "../../stores" +import { newToast } from "../../utils/common" +import { translate } from "../../utils/language" import { send } from "../../utils/request" -import { setOutput } from "./output" -import { loadShows } from "./setShow" -import { _show } from "./shows" +import { actionData } from "../actions/actionData" +import { customActionActivation, runAction } from "../actions/actions" import { clone, sortByTime } from "./array" -import { checkNextAfterMedia, updateOut } from "./showActions" +import { loadShows } from "./setShow" +import { checkNextAfterMedia } from "./showActions" const INTERVAL = 1000 const TEN_SECONDS = 1000 * 10 const ONE_MINUTE = 1000 * 60 let timeout: any = null +let customInterval = INTERVAL export function startTimer() { if (get(currentWindow)) return if (!get(activeTimers).filter((a) => a.paused !== true).length || timeout) return @@ -28,77 +30,129 @@ export function startTimer() { timeout = null startTimer() - }, INTERVAL) + }, customInterval) } export function stopTimers() { activeTimers.set([]) + customInterval = INTERVAL } -function increment(timer: any) { - if (timer.start < timer.end ? timer.currentTime >= timer.end : timer.currentTime <= timer.end) checkNextAfterMedia(timer.id, "timer") +function increment(timer: any, i: number) { + if (timer.start < timer.end ? timer.currentTime >= timer.end : timer.currentTime <= timer.end) { + // ended + checkNextAfterMedia(timer.id, "timer") + customActionActivation("timer_end") + } if ((timer.currentTime === timer.end && !timer.overflow) || timer.paused) return timer - if (timer.start < timer.end) timer.currentTime++ - else timer.currentTime-- + + let currentTime = Date.now() + // store timer start time (for accuracy) + if (!timer.startTime) { + let timerIs = timer.currentTime - timer.start + let timerShouldBe = timerIs * 1000 // - 1 + if (timer.start < timer.end) timer.startTime = currentTime - timerShouldBe + else timer.startTime = currentTime + timerShouldBe + } + + let difference = currentTime - timer.startTime + let timerShouldBe = Math.floor(difference / 1000) + 1 + + // prevent interval time increasing more and more + if (i === 0) { + let preciseTime = (timerShouldBe - 1) * 1000 + let differenceMs = difference - preciseTime + customInterval = Math.max(500, INTERVAL - differenceMs) + } + + if (timer.start < timer.end) timer.currentTime = timer.start + timerShouldBe + else timer.currentTime = timer.start - timerShouldBe return timer } -let showTimeout: any = null +// convert "show" to "action" <= 1.1.7 +function convertShowToAction() { + events.update((a) => { + Object.keys(a).forEach((eventId) => { + let event = a[eventId] + if (event.type === "show") { + event.type = "action" + event.action = { id: "start_show", data: { id: event.show } } + } + }) + return a + }) +} + +let actionTimeout: any = null +let initialized: boolean = false export function startEventTimer() { + if (actionTimeout) return + actionTimeout = true + + if (!initialized) { + initialized = true + convertShowToAction() + } + let currentTime: Date = new Date() - let showEvents: Event[] = Object.values(get(events)).filter((a) => { + let actionEvents: Event[] = Object.values(get(events)).filter((a) => { let eventTime: Date = new Date(a.from) - return a.type === "show" && currentTime.getTime() - INTERVAL < eventTime.getTime() + return a.type === "action" && currentTime.getTime() - INTERVAL < eventTime.getTime() }) - if (!showEvents.length || showTimeout) { - nextShowEventStart.set({}) - return - } - showEvents = showEvents.sort(sortByTime) + if (!actionEvents.length) nextActionEventStart.set({}) + + actionEvents = actionEvents.sort(sortByTime) + + actionTimeout = setTimeout(() => { + actionEvents.forEach((event, i) => { + if (!event.action) return - showTimeout = setTimeout(() => { - showEvents.forEach((event, i) => { let eventTime: Date = new Date(event.from) let toast = get(dictionary).toast || {} - let showId = event.show || "" - let show = get(shows)[showId] - if (!show || get(nextShowEventPaused)) return + if (get(nextActionEventPaused)) return + + let actionId = event.action.id + let actionName = translate(actionData[actionId]?.name) let timeLeft: number = eventTime.getTime() - currentTime.getTime() - if (i === 0) { - nextShowEventStart.set({ showId, name: show.name, timeLeft }) - } + if (i === 0) nextActionEventStart.set({ name: actionName, timeLeft }) // less than 1 minute - if (timeLeft <= ONE_MINUTE && timeLeft > ONE_MINUTE - INTERVAL) { - newToast(`${toast.starting_show} "${show.name}" ${toast.less_than_minute}`) + if (i < 4 && timeLeft <= ONE_MINUTE && timeLeft > ONE_MINUTE - INTERVAL) { + newToast(`${toast.starting_action} "${actionName}" ${toast.less_than_minute}`) + return } // less than 30 seconds - if (timeLeft <= ONE_MINUTE / 2 && timeLeft > ONE_MINUTE / 2 - INTERVAL) { - newToast(`${toast.starting_show} "${show.name}" ${toast.less_than_seconds.replace("{}", "30")}`) + if (i < 4 && timeLeft <= ONE_MINUTE / 2 && timeLeft > ONE_MINUTE / 2 - INTERVAL) { + newToast(`${toast.starting_action} "${actionName}" ${toast.less_than_seconds.replace("{}", "30")}`) + return } // less than 10 seconds - if (timeLeft <= TEN_SECONDS && timeLeft > TEN_SECONDS - INTERVAL) { - newToast(`${toast.starting_show} "${show.name}" ${toast.less_than_seconds.replace("{}", "10")}`) - loadShows([showId]) + if (i < 4 && timeLeft <= TEN_SECONDS && timeLeft > TEN_SECONDS - INTERVAL) { + newToast(`${toast.starting_action} "${actionName}" ${toast.less_than_seconds.replace("{}", "10")}`) + + // preload data + if (actionId === "start_show") loadShows([event.action.data?.id]) + return } - // start show + + // start action if (timeLeft <= 0 && timeLeft > 0 - INTERVAL) { - newToast(`${toast.starting_show} "${show.name}" ${toast.now}`) - loadShows([showId]) - let activeLayout = _show(event.show).get("settings.activeLayout") - - // slideClick() - Slides.svelte - let slideRef: any = _show(showId).layouts("active").ref()[0] - updateOut(showId, 0, slideRef) - setOutput("slide", { id: showId, layout: activeLayout, index: 0, line: 0 }) + newToast(`${toast.starting_action} "${actionName}" ${toast.now}`) + + runAction(convertEventAction(event.action)) } }) - showTimeout = null + actionTimeout = null startEventTimer() }, INTERVAL) } + +function convertEventAction(action) { + return { triggers: [action.id], actionValues: { [action.id]: action.data || {} } } +} diff --git a/src/frontend/components/inputs/Button.svelte b/src/frontend/components/inputs/Button.svelte index 80d52d5a..f65cc4e8 100644 --- a/src/frontend/components/inputs/Button.svelte +++ b/src/frontend/components/inputs/Button.svelte @@ -95,6 +95,8 @@ align-items: center; padding: 0.2em 0.8em; + border-radius: var(--border-radius); + transition: background-color 0.2s, border 0.2s; @@ -203,6 +205,7 @@ position: fixed; background-color: var(--primary-darkest); border: 2px solid var(--primary-lighter); + border-radius: var(--border-radius); padding: 5px 10px; top: 0; left: 0; diff --git a/src/frontend/components/inputs/Checkbox.svelte b/src/frontend/components/inputs/Checkbox.svelte index ae163482..42bf08a5 100644 --- a/src/frontend/components/inputs/Checkbox.svelte +++ b/src/frontend/components/inputs/Checkbox.svelte @@ -16,6 +16,7 @@ height: 1em; font-size: 22px; background-color: var(--primary-lighter); + border-radius: var(--border-radius); } .switch.disabled { @@ -36,6 +37,7 @@ width: 1em; height: 1em; background-color: var(--text); + border-radius: var(--border-radius); transition: all 300ms; } .switch div.on { diff --git a/src/frontend/components/inputs/Color.svelte b/src/frontend/components/inputs/Color.svelte index 8cdabfc8..63ede4f7 100644 --- a/src/frontend/components/inputs/Color.svelte +++ b/src/frontend/components/inputs/Color.svelte @@ -128,6 +128,7 @@ border: 2px solid var(--primary-darker); transition: background-color 0.2s; position: relative; + border-radius: var(--border-radius); } /* filter: brightness(0.98); */ /* .color:not(.picker):hover { diff --git a/src/frontend/components/inputs/CombinedInput.svelte b/src/frontend/components/inputs/CombinedInput.svelte index 4324a284..edf79b86 100644 --- a/src/frontend/components/inputs/CombinedInput.svelte +++ b/src/frontend/components/inputs/CombinedInput.svelte @@ -22,6 +22,7 @@ background-color: var(--primary-darker); border-bottom: 2px solid var(--primary-lighter); + border-radius: var(--border-radius); } .input :global(*:not(:first-child)) { diff --git a/src/frontend/components/inputs/Dropdown.svelte b/src/frontend/components/inputs/Dropdown.svelte index b90a6f80..c4b871c8 100644 --- a/src/frontend/components/inputs/Dropdown.svelte +++ b/src/frontend/components/inputs/Dropdown.svelte @@ -107,6 +107,7 @@ background-color: var(--primary-darker); color: var(--text); /* position: relative; */ + border-radius: var(--border-radius); } div.disabled { @@ -153,6 +154,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + border-radius: var(--border-radius); } button { diff --git a/src/frontend/components/inputs/FolderPicker.svelte b/src/frontend/components/inputs/FolderPicker.svelte index aaa1d77d..4e84dce8 100644 --- a/src/frontend/components/inputs/FolderPicker.svelte +++ b/src/frontend/components/inputs/FolderPicker.svelte @@ -1,6 +1,7 @@ diff --git a/src/frontend/components/inputs/FontDropdown.svelte b/src/frontend/components/inputs/FontDropdown.svelte index 01c14ffc..c6bc3418 100644 --- a/src/frontend/components/inputs/FontDropdown.svelte +++ b/src/frontend/components/inputs/FontDropdown.svelte @@ -1,10 +1,11 @@ -
+
--> diff --git a/src/frontend/components/inputs/TextInput.svelte b/src/frontend/components/inputs/TextInput.svelte index fc8e9652..c0d868f6 100644 --- a/src/frontend/components/inputs/TextInput.svelte +++ b/src/frontend/components/inputs/TextInput.svelte @@ -17,6 +17,7 @@ diff --git a/src/frontend/components/main/popups/Action.svelte b/src/frontend/components/main/popups/Action.svelte index be2c5ac2..06d508b2 100644 --- a/src/frontend/components/main/popups/Action.svelte +++ b/src/frontend/components/main/popups/Action.svelte @@ -1,6 +1,6 @@
- {#if mode === "slide"} + {#if mode === "slide" || mode === "template"} {:else} {#if mode !== "slide_midi"} @@ -202,6 +260,12 @@

updateValue("name", e)} /> + +

+
+ updateValue("enabled", e, true)} /> +
+
@@ -228,12 +292,10 @@ {#if !mode} - -

-
- updateValue("startupEnabled", e, true)} /> -
+

+ a.id === customActivation)?.name || "—"} on:click={(e) => updateValue("customActivation", e.detail.id)} />
+

diff --git a/src/frontend/components/main/popups/Alert.svelte b/src/frontend/components/main/popups/Alert.svelte index ec4e53c7..70b31467 100644 --- a/src/frontend/components/main/popups/Alert.svelte +++ b/src/frontend/components/main/popups/Alert.svelte @@ -5,6 +5,7 @@ import Icon from "../../helpers/Icon.svelte" import T from "../../helpers/T.svelte" import Button from "../../inputs/Button.svelte" + import Link from "../../inputs/Link.svelte" let msg: string = "" $: msg = $alertMessage.toString() @@ -23,7 +24,12 @@

{#key msg} - {#if !msg.includes("<") && msg?.length - msg?.replaceAll(".", "").length === 1} + {#if msg.includes("captions#")} + +
+
+ {msg.slice(msg.indexOf("#") + 1)} + {:else if !msg.includes("<") && msg?.length - msg?.replaceAll(".", "").length === 1} {:else} {@html msg} diff --git a/src/frontend/components/main/popups/ChangeOutputValues.svelte b/src/frontend/components/main/popups/ChangeOutputValues.svelte index 534584fa..de0a287d 100644 --- a/src/frontend/components/main/popups/ChangeOutputValues.svelte +++ b/src/frontend/components/main/popups/ChangeOutputValues.svelte @@ -6,6 +6,7 @@ import T from "../../helpers/T.svelte" import { displayOutputs } from "../../helpers/output" import Button from "../../inputs/Button.svelte" + import Checkbox from "../../inputs/Checkbox.svelte" import CombinedInput from "../../inputs/CombinedInput.svelte" import NumberInput from "../../inputs/NumberInput.svelte" @@ -30,11 +31,21 @@ getCurrentOutput(currentOutput.id) } + + const isChecked = (e: any) => e.target.checked

- diff --git a/src/frontend/components/main/popups/CreatePlayer.svelte b/src/frontend/components/main/popups/CreatePlayer.svelte index 8a4ad2f3..d876875c 100644 --- a/src/frontend/components/main/popups/CreatePlayer.svelte +++ b/src/frontend/components/main/popups/CreatePlayer.svelte @@ -1,7 +1,7 @@ @@ -169,6 +207,46 @@ {/if} +{#if songs !== null} + +{/if} + +{#if showSearchResults} +
+ + + + + + + + + + {#if songs} + {#each songs as song} + { + getLyrics(song) + }} + > + + + + + {/each} + {:else} + + + + {/if} + +
{song.title}{song.artist}{song.source}
No songs found
+
+{/if} + - -{/if} - - -
- - - - -{#if editEvent.group} - -{/if} + {#if editEvent.group} + + + + {/if} +
diff --git a/src/frontend/components/main/popups/NextTimer.svelte b/src/frontend/components/main/popups/NextTimer.svelte index 7c614e3a..4a67c3db 100644 --- a/src/frontend/components/main/popups/NextTimer.svelte +++ b/src/frontend/components/main/popups/NextTimer.svelte @@ -12,6 +12,7 @@ let value = $popupData.value || 0 let layoutRef: any[] = _show().layouts("active").ref()[0] + let allActiveSlides = layoutRef.filter((a) => !a.data.disabled) let indexes = $popupData.indexes || layoutRef.map((_, i) => i) let allSlides: boolean = !$popupData.indexes?.length @@ -34,18 +35,16 @@ } // total time - let totalTime: string = "0s" + let totalTime: number = 0 function getTotalTime() { - layoutRef = _show() - .layouts("active") - .ref()[0] - .filter((a) => !a.data.disabled) - let total = layoutRef.reduce((value, ref) => (value += Number(ref.data.nextTimer || 0)), 0) - - totalTime = total ? (total > 59 ? joinTime(secondsToTime(total)) : total + "s") : "0s" + totalTime = allActiveSlides.reduce((value, ref) => (value += Number(ref.data.nextTimer || 0)), 0) } - let allTime: number = 10 + let allTime: number = allActiveSlides[0]?.data?.nextTimer || 10 + + $: newTime = allTime * allActiveSlides.length + + const getTime = (time: number) => (time > 59 ? joinTime(secondsToTime(time)) : time + "s") {#if allSlides} @@ -54,14 +53,19 @@

- : {totalTime} + : {getTime(totalTime)}

diff --git a/src/frontend/components/media/Video.svelte b/src/frontend/components/media/Video.svelte index cfa48cff..95ba44a9 100644 --- a/src/frontend/components/media/Video.svelte +++ b/src/frontend/components/media/Video.svelte @@ -1,7 +1,6 @@ diff --git a/src/frontend/components/output/animation.ts b/src/frontend/components/output/animation.ts index 3096c935..77e25d1e 100644 --- a/src/frontend/components/output/animation.ts +++ b/src/frontend/components/output/animation.ts @@ -1,4 +1,5 @@ import { activeAnimate } from "../../stores" +import { wait } from "../../utils/common" import { clone } from "../helpers/array" export async function updateAnimation(animationData: any, currentIndex: number, outSlide: any) { @@ -87,14 +88,6 @@ const animations = { }, } -export function wait(ms: number) { - return new Promise((resolve) => { - setTimeout(() => { - resolve("ended") - }, Number(ms)) - }) -} - function removePreviousKeys(array: string[] | undefined, key: string) { if (!array) return [] return array.filter((a) => !a.includes(key)) diff --git a/src/frontend/components/output/layers/Background.svelte b/src/frontend/components/output/layers/Background.svelte index 3147c869..5c96c450 100644 --- a/src/frontend/components/output/layers/Background.svelte +++ b/src/frontend/components/output/layers/Background.svelte @@ -15,12 +15,13 @@ export let animationStyle: string = "" export let mirror: boolean = false - $: duration = transition.duration || 800 + $: duration = transition.duration ?? 800 $: style = `height: 100%;zoom: ${1 / ratio};transition: filter ${duration}ms, backdrop-filter ${duration}ms;${slideFilter}` let firstActive: boolean = true let background1: any = null let background2: any = null + let firstFadingOut: boolean = false let loading: boolean = false let timeout: any = null @@ -49,6 +50,7 @@ () => { loading = true let loadingFirst = !background1 + firstFadingOut = !loadingFirst if (!background1) background1 = clone(data) else background2 = clone(data) @@ -89,12 +91,12 @@
{#if background1}
- loaded(true)} /> + loaded(true)} />
{/if} {#if background2}
- loaded(false)} /> + loaded(false)} />
{/if}
diff --git a/src/frontend/components/output/layers/BackgroundMedia.svelte b/src/frontend/components/output/layers/BackgroundMedia.svelte index 4559b836..05d11928 100644 --- a/src/frontend/components/output/layers/BackgroundMedia.svelte +++ b/src/frontend/components/output/layers/BackgroundMedia.svelte @@ -1,10 +1,11 @@ @@ -87,17 +92,30 @@

-{#each g as group} +{#each g as group, i} + {#if i === 0} +
+

+

+

+

+ +
+ {/if} + - changeGroup(e, group.id)} /> - - changeGroup(e.detail, group.id, "color")} /> + + changeGroup(e, group.id)} /> + + changeGroup(e.detail, group.id, "color")} /> - changeGroup(e, group.id, "shortcut")} center /> + + changeGroup(e, group.id, "shortcut")} center /> + + + + a.id === group.template)?.name || "—"} options={templateList} on:click={(e) => changeGroup(e.detail.id, group.id, "template")} center /> +
diff --git a/src/frontend/components/slide/Icons.svelte b/src/frontend/components/slide/Icons.svelte index 4bc4d8a1..b3e06a45 100644 --- a/src/frontend/components/slide/Icons.svelte +++ b/src/frontend/components/slide/Icons.svelte @@ -1,10 +1,11 @@ @@ -324,21 +350,13 @@ {#if !altKeyPressed && bg && (viewMode !== "lyrics" || noQuickEdit)} {#key $refreshSlideThumbnails}
- + +
{/key} {/if} {#if slide.items} - {#each slide.items as item, i} + {#each invertedItemList as item, i} {#if item && (viewMode !== "lyrics" || item.type === undefined || ["text", "events", "list"].includes(item.type))} {#key $refreshListBoxes >= 0 && $refreshListBoxes !== index} {#if slide.items} - {#each slide.items as item, itemIndex} + {#each invertedItemList as item, itemIndex} {#if item.lines} {/if} diff --git a/src/frontend/components/slide/Textbox.svelte b/src/frontend/components/slide/Textbox.svelte index 26c60a2b..79aed2c6 100644 --- a/src/frontend/components/slide/Textbox.svelte +++ b/src/frontend/components/slide/Textbox.svelte @@ -7,7 +7,7 @@ import { getAutoSize } from "../edit/scripts/autoSize" import Icon from "../helpers/Icon.svelte" import { clone } from "../helpers/array" - import { getExtension, getMediaType } from "../helpers/media" + import { getExtension, getMediaType, loadThumbnail, mediaSize } from "../helpers/media" import { replaceDynamicValues } from "../helpers/showActions" import { _show } from "../helpers/shows" import { getStyles } from "../helpers/style" @@ -20,6 +20,7 @@ import Variable from "./views/Variable.svelte" import Visualizer from "./views/Visualizer.svelte" import Website from "./views/Website.svelte" + import Captions from "./views/Captions.svelte" export let item: Item export let itemIndex: number = -1 @@ -72,7 +73,7 @@ onMount(() => { setTimeout(() => (loaded = true), 100) - if (item.type !== "timer") return + if (item?.type !== "timer") return setInterval(() => (today = new Date()), 500) }) @@ -346,6 +347,24 @@ let paddingCorrection = {} $: paddingCorrection = getPaddingCorrection(stageItem) + + let mediaItemPath = "" + $: if (item?.type === "media") getMediaItemPath() + async function getMediaItemPath() { + mediaItemPath = item.src || "" + + // only load thumbnails in main + if ($currentWindow) return + + let newPath = await loadThumbnail(mediaItemPath, mediaSize.slideSize) + if (newPath) mediaItemPath = newPath + } + + // UPDATE DYNAMIC VALUES e.g. {time_} EVERY SECOND + let updateDynamic = 0 + setInterval(() => { + updateDynamic++ + }, 1000) @@ -393,7 +412,7 @@ {#each line.text || [] as text} {@const value = text.value.replaceAll("\n", "
") || "
"} - {@html dynamicValues && value.includes("{") ? replaceDynamicValues(value, { showId: ref.showId, layoutId: ref.layoutId, slideIndex }) : value} + {@html dynamicValues && value.includes("{") ? replaceDynamicValues(value, { showId: ref.showId, layoutId: ref.layoutId, slideIndex }, updateDynamic) : value} {/each}
@@ -408,11 +427,10 @@ {:else if item?.type === "list"} {:else if item?.type === "media"} - {#if item.src} - {#if getMediaType(getExtension(item.src)) === "video"} - + {#if mediaItemPath} + {#if $currentWindow && getMediaType(getExtension(mediaItemPath)) === "video"}