Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 595ea297f5 | |||
| a948780745 | |||
| 8c899a6469 | |||
| 4f4cdcd8c0 | |||
| 602be3a724 | |||
| bb7bd21102 | |||
| 543bfe074d | |||
| 81c50eff16 | |||
| bf0fb5741f | |||
| 6d2355f404 | |||
| 732d585273 | |||
| 666808f876 | |||
| 3ec9812265 | |||
| 64f3c5eefd | |||
| 3fe074b308 | |||
| a06cca8b9d | |||
| c4fc3ab53b | |||
| 3f3fea58ed | |||
| 014169f84e | |||
| 9c61abee03 | |||
| 2f11c60307 | |||
| 27301f8746 | |||
| 030b839b12 | |||
| 5c805bcafa | |||
| eb83d9148e | |||
| 290360387f | |||
| c56e404c13 | |||
| e519ea2302 | |||
| 5a75bae8d0 | |||
| fc3f4f38ff | |||
| f69da7542e | |||
| aba05060b9 | |||
| 0b986e6e13 | |||
| b23ce8ee7d | |||
| 3195bd0089 | |||
| 1c1778d265 | |||
| 0262d9c1ad | |||
| f23c329709 | |||
| 78a165d368 | |||
| 63b5ef0ffd | |||
| a26b07cb5c | |||
| a445759dd7 | |||
| 1e883d8ee4 | |||
| be574e0e25 | |||
| ebf3178e32 | |||
| ba96cf2765 | |||
| d026ed891a |
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Create a report to help us improve KoalaSync
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ Required:** Before submitting, open the KoalaSync **Status** tab in the extension popup and click **"Copy Logs"**. Paste the full output below — it contains essential system info, connection state, and debug data needed to diagnose your issue.
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Copy Logs Output</b></summary>
|
||||
|
||||
<!-- Paste the copied logs here -->
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Describe the Bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
### To Reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
A clear and concise description of what actually happened.
|
||||
|
||||
### Screenshots / Screen Recordings
|
||||
|
||||
If applicable, add screenshots or recordings to help explain your problem.
|
||||
|
||||
### Environment
|
||||
|
||||
- **Browser:** (e.g. Chrome 125, Firefox 128)
|
||||
- **Extension Version:** (visible at the bottom of the Settings tab)
|
||||
- **OS:** (e.g. Windows 11, macOS 14.5)
|
||||
- **Self-Hosted Server?:** (yes / no — if yes, provide server version)
|
||||
- **Website/Platform:** (e.g. YouTube, Netflix, Twitch, Jellyfin, Emby)
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context about the problem here (e.g. network setup, VPN usage, multiple monitors, etc.).
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 🚀 Feature Request
|
||||
about: Suggest a new feature or enhancement for KoalaSync
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Problem Description
|
||||
|
||||
A clear and concise description of the problem or limitation you're encountering. What's missing or what could be improved?
|
||||
|
||||
*As a user, I want to ... so that ...*
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Describe the feature you'd like to see. How should it work? Be as specific as possible.
|
||||
|
||||
### Use Cases & Benefits
|
||||
|
||||
- **When would you use this?** (e.g., specific websites, workflows, setups)
|
||||
- **What value does it add?** (e.g., saves time, enables new functionality, improves UX)
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
What workarounds or alternative approaches have you tried? Are there other ways to achieve a similar result?
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context, sketches, mockups, or references (e.g., links to similar features in other projects).
|
||||
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Thanks for contributing to KoalaSync!
|
||||
|
||||
Please read the CONTRIBUTING.md and CODE_OF_CONDUCT.md before submitting.
|
||||
By submitting this PR, you agree to abide by our code of conduct.
|
||||
|
||||
Use conventional commits for the title: feat:, fix:, docs:, refactor:, chore:
|
||||
-->
|
||||
|
||||
### Description
|
||||
|
||||
<!-- What does this PR do and why? Link to relevant issue or motivation. -->
|
||||
|
||||
Closes #
|
||||
|
||||
### Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that alters existing behavior)
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Documentation update
|
||||
- [ ] Build, dependencies, or CI
|
||||
|
||||
### How Has This Been Tested?
|
||||
|
||||
<!-- Describe the tests you ran and the environments (browsers, OS, etc.) -->
|
||||
|
||||
- [ ] Tested on Chrome
|
||||
- [ ] Tested on Firefox
|
||||
- [ ] `npm run lint` passes with zero errors and zero warnings
|
||||
- [ ] `node -c` passes on all modified `.js` files
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have added/updated tests if needed
|
||||
- [ ] I have updated documentation if needed (`docs/`, README, etc.)
|
||||
- [ ] Protocol changes: I ran `node scripts/build-extension.js` and updated relevant docs
|
||||
- [ ] No new warnings, secrets, or hardcoded credentials introduced
|
||||
|
||||
### Additional Context
|
||||
|
||||
<!-- Screenshots, migration notes, performance data, etc. -->
|
||||
@@ -0,0 +1,95 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.5] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
|
||||
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
|
||||
|
||||
### Added
|
||||
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
|
||||
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.4] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
|
||||
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
|
||||
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
|
||||
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
|
||||
- Lowered the default maximum peers per room to 25.
|
||||
|
||||
### Added
|
||||
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
|
||||
|
||||
### Changed
|
||||
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
|
||||
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
|
||||
|
||||
### Fixed
|
||||
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
|
||||
- Fixed join-button timeout cleanup after join status responses.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] — 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
|
||||
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] — 2026-06-01
|
||||
|
||||
### Fixed
|
||||
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
|
||||
- History entries in debug report showing `?` instead of action names.
|
||||
- Prime Video status in compatibility matrix updated to reflect partial support.
|
||||
|
||||
### Added
|
||||
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.0] — 2026-06-01
|
||||
|
||||
### 🌍 Multi-Language Extension (Biggest Feature!)
|
||||
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
|
||||
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
|
||||
|
||||
### New Features
|
||||
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
|
||||
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
|
||||
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
|
||||
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
|
||||
|
||||
### Changed
|
||||
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
|
||||
- **Larger Popup Logo**: Extension popup icon increased to 48px.
|
||||
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
|
||||
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
|
||||
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
|
||||
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
|
||||
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
|
||||
- **Reconnection Strategy**: Custom server reconnection improvements.
|
||||
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
|
||||
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
|
||||
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
|
||||
|
||||
### Fixed
|
||||
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
|
||||
|
||||
---
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
|
||||
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
|
||||
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainer at **koaladev@koalamail.rocks**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project maintainers are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Project maintainers will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from project maintainers, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Thanks for your interest in improving KoalaSync. All contributions are welcome — from bug reports and translations to core protocol changes.
|
||||
|
||||
Please note that by participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
---
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
@@ -7,7 +7,7 @@ KoalaSync is designed with a **Security-First & Volatile** architecture. This me
|
||||
## 1. Data Processing (In-Memory Only)
|
||||
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
|
||||
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
|
||||
|
||||
### Data Retention
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.2 Release!</b> — See what's changed</a></p>
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
@@ -104,10 +106,12 @@ Both the official KoalaSync website and the **v2.0 Browser Extension** feature f
|
||||
|
||||
### 📖 Documentation & Links
|
||||
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: Full version history for the extension and relay server.
|
||||
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
|
||||
- **[TRANSLATION.md](website/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
|
||||
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
|
||||
@@ -73,8 +73,9 @@ Encrypt sensitive findings with our PGP key (available on request).
|
||||
KoalaSync's security is grounded in its architecture:
|
||||
|
||||
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
|
||||
- **bcrypt room passwords**: Plaintext passwords never stored. Brute-force protection: 5 attempts → 15-minute IP lockout.
|
||||
- **Rate limiting**: Connection rate (IP-based, 60s window) and event rate (per-socket, 10s window).
|
||||
- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout.
|
||||
- **Rate limiting**: Connection rate (IP-based, 60s window), health endpoint rate (10 requests/minute/IP), wrong admin-metrics bearer attempts (5 requests/minute/IP), and event rate (per-socket, 10s window). Health-style JSON responses are cached server-side for 60 seconds and refreshed lazily on request.
|
||||
- **Reverse proxy boundary**: The relay trusts one proxy hop for client IP detection. In production, keep the Node server reachable only through Caddy or another trusted reverse proxy.
|
||||
- **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server.
|
||||
- **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`.
|
||||
- **No third-party requests**: Zero CDNs, fonts, analytics, or external scripts.
|
||||
|
||||
@@ -11,7 +11,7 @@ This document tracks which streaming platforms and media servers have been teste
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Plex** | Not tested | Not tested | Not tested | Community reports indicate compatibility via HTML5 player mode. |
|
||||
| **Disney+** | Not tested | Not tested | Not tested | Widevine DRM may restrict title detection similar to Netflix. |
|
||||
| **Prime Video** | ❌ None | ❌ None | ❌ | Extension cannot detect video elements. Play/Pause do not work. Media title detection fails. DRM and custom player prevent script access. |
|
||||
| **Prime Video** | ⚠️ Partial | ⚠️ Partial | ❌ | Video elements detected (2 on page, picks larger one). Playback state + time readable. However, the preview/trailer video may be selected instead of the main content. Play/Pause commands may not reach the correct player. Title detection from MediaSession API may work for some content. |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — |
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 889 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -8,7 +8,8 @@ services:
|
||||
- PORT=3000 # Port KoalaSync listens on inside the container
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
- caddy_net # Joins the pre-existing Caddy network for reverse proxying
|
||||
|
||||
@@ -10,7 +10,8 @@ services:
|
||||
- PORT=3000 # Port KoalaSync listens on inside the container
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
bond0_network: # Network name as referenced by the service
|
||||
|
||||
@@ -58,7 +58,7 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
|
||||
## 7. Security & Stability
|
||||
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
|
||||
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS. Real client IP extracted via `x-forwarded-for` header behind proxies/CDNs.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or simple DoS. Public health endpoints are limited to 10 requests/minute/IP and cached server-side for 60 seconds, wrong admin-metrics bearer attempts to 5 requests/minute/IP, and room discovery to one request every 10 seconds per socket. Real client IP is taken from the trusted reverse proxy hop, so the Node port must stay private behind Caddy or another trusted proxy.
|
||||
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
|
||||
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
|
||||
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
|
||||
|
||||
@@ -51,7 +51,7 @@ Click **"Create Room"** in the popup's Room tab:
|
||||
|
||||
4. **Server-side processing**:
|
||||
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
|
||||
- The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored).
|
||||
- The server **hashes the password** with a keyed SHA-256 HMAC and stores only that hash in RAM (the plaintext is never stored).
|
||||
- A new room object is created in memory with the peer's data.
|
||||
- The server responds with `ROOM_DATA` containing the list of peers in the room.
|
||||
|
||||
@@ -89,7 +89,7 @@ When your friend opens the link in their browser:
|
||||
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
|
||||
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
|
||||
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
|
||||
- The server validates the password against the stored bcrypt hash.
|
||||
- The server validates the password against the stored keyed SHA-256 HMAC hash.
|
||||
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
|
||||
- The join page updates to show "✅ Successfully joined!".
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
self: "readonly"
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
|
||||
@@ -56,7 +56,7 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
@@ -80,6 +80,8 @@ function ensureState() {
|
||||
if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed;
|
||||
if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime;
|
||||
if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts;
|
||||
if (data.roomIdleSince !== undefined) roomIdleSince = data.roomIdleSince;
|
||||
if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt;
|
||||
|
||||
// Recover Force Sync Timeout
|
||||
if (data.forceSyncDeadline) {
|
||||
@@ -139,9 +141,12 @@ let reconnectStartTime = null;
|
||||
let reconnectFailed = false;
|
||||
let reconnectAttempts = 0;
|
||||
let currentServerUrl = null;
|
||||
let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
@@ -304,6 +309,8 @@ function forceDisconnect() {
|
||||
isNamespaceJoined = false;
|
||||
isForceSyncInitiator = false;
|
||||
expectedAcksCount = 0;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
forceSyncAcks.clear();
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({
|
||||
@@ -312,7 +319,9 @@ function forceDisconnect() {
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
eventQueue: [],
|
||||
episodeLobby: null
|
||||
episodeLobby: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null
|
||||
}).catch(() => {});
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
@@ -322,6 +331,63 @@ function forceDisconnect() {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
}
|
||||
|
||||
function persistRoomIdleState() {
|
||||
chrome.storage.session.set({ roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
}
|
||||
|
||||
function markRoomUseful() {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = Date.now();
|
||||
persistRoomIdleState();
|
||||
}
|
||||
|
||||
function markRoomPotentiallyIdle() {
|
||||
if (!currentRoom) {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
persistRoomIdleState();
|
||||
return;
|
||||
}
|
||||
if (!roomIdleSince) {
|
||||
roomIdleSince = Date.now();
|
||||
persistRoomIdleState();
|
||||
}
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle() {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
await chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
episodeLobby: null
|
||||
}).catch(() => {});
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (isConnecting) return;
|
||||
isConnecting = true;
|
||||
@@ -482,12 +548,15 @@ async function connect() {
|
||||
|
||||
socket.onerror = () => {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog('WebSocket Error: Connection failed', 'error');
|
||||
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
||||
addLog('WebSocket Error: Connection failed', logType);
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
isConnecting = false;
|
||||
addLog(e.message, 'error');
|
||||
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
|
||||
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
|
||||
addLog(errMsg, logType);
|
||||
broadcastConnectionStatus('disconnected');
|
||||
scheduleReconnect();
|
||||
}
|
||||
@@ -628,6 +697,7 @@ function handleServerEvent(event, data) {
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
markRoomPotentiallyIdle();
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
|
||||
@@ -1149,8 +1219,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
}).catch(err => {
|
||||
if (retries >= 3) {
|
||||
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
clearTargetTabForIdle();
|
||||
return;
|
||||
}
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
@@ -1164,8 +1233,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
});
|
||||
} else {
|
||||
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
clearTargetTabForIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1181,6 +1249,15 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
const now = Date.now();
|
||||
const heartbeatAge = lastContentHeartbeatAt ? (now - lastContentHeartbeatAt) : Infinity;
|
||||
if (!currentTabId || heartbeatAge > 45000) {
|
||||
markRoomPotentiallyIdle();
|
||||
}
|
||||
if (roomIdleSince && Date.now() - roomIdleSince >= ROOM_IDLE_AUTO_LEAVE_MS) {
|
||||
await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.');
|
||||
return;
|
||||
}
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
@@ -1284,6 +1361,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
@@ -1297,6 +1377,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
@@ -1489,6 +1573,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
@@ -1516,7 +1601,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
currentTabId = message.tabId;
|
||||
currentTabTitle = message.tabTitle;
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle });
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
|
||||
if (currentTabId) {
|
||||
@@ -1642,7 +1731,9 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
|
||||
lastContentHeartbeatAt = null;
|
||||
roomIdleSince = Date.now();
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
|
||||
@@ -78,21 +78,40 @@
|
||||
}
|
||||
|
||||
// --- Helper: find the best video element on the page ---
|
||||
// Prefers larger, visible videos over tiny preview/trailer elements.
|
||||
function findVideo(root = document) {
|
||||
const video = root.querySelector('video');
|
||||
if (video) return video;
|
||||
|
||||
// Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive)
|
||||
// or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.)
|
||||
// while guaranteeing 100% airtight compatibility with all video web components in the wild.
|
||||
const candidates = Array.from(root.querySelectorAll('video'));
|
||||
|
||||
// Scan likely media hosts even when light-DOM videos exist; many players
|
||||
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
|
||||
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
||||
for (const el of potentialHosts) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) return found;
|
||||
if (found) candidates.push(found);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Multiple videos found → pick the best one
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
for (const v of candidates) {
|
||||
if (v.tagName !== 'VIDEO') continue;
|
||||
// Score: visible area + bonus for unmuted + bonus for longer duration
|
||||
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
|
||||
const unmutedBonus = v.muted ? 0 : 100000;
|
||||
const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100;
|
||||
const score = area + unmutedBonus + durationBonus;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
@@ -242,8 +261,8 @@
|
||||
|
||||
try {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const isYouTube = host.includes('youtube.com');
|
||||
const isTwitch = host.includes('twitch.tv');
|
||||
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com');
|
||||
const isTwitch = host === 'twitch.tv' || host.endsWith('.twitch.tv');
|
||||
|
||||
if (isYouTube) {
|
||||
const ytButton = document.querySelector('.ytp-play-button');
|
||||
@@ -436,15 +455,15 @@
|
||||
|
||||
const platform = (() => {
|
||||
const h = window.location.hostname.toLowerCase();
|
||||
if (h.includes('youtube.com')) return 'YouTube';
|
||||
if (h.includes('twitch.tv')) return 'Twitch';
|
||||
if (h.includes('netflix.com')) return 'Netflix';
|
||||
if (h.includes('primevideo.com') || h.includes('amazon.')) return 'Prime Video';
|
||||
if (h.includes('disneyplus.com')) return 'Disney+';
|
||||
if (h.includes('hulu.com')) return 'Hulu';
|
||||
if (h.includes('max.com') || h.includes('hbomax.com')) return 'Max/HBO';
|
||||
if (h.includes('vimeo.com')) return 'Vimeo';
|
||||
if (h.includes('dailymotion.com')) return 'Dailymotion';
|
||||
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
|
||||
if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch';
|
||||
if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix';
|
||||
if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video';
|
||||
if (h === 'disneyplus.com' || h.endsWith('.disneyplus.com')) return 'Disney+';
|
||||
if (h === 'hulu.com' || h.endsWith('.hulu.com')) return 'Hulu';
|
||||
if (h === 'hbomax.com' || h.endsWith('.hbomax.com') || h === 'max.com' || h.endsWith('.max.com')) return 'Max/HBO';
|
||||
if (h === 'vimeo.com' || h.endsWith('.vimeo.com')) return 'Vimeo';
|
||||
if (h === 'dailymotion.com' || h.endsWith('.dailymotion.com')) return 'Dailymotion';
|
||||
return 'Generic';
|
||||
})();
|
||||
|
||||
@@ -468,6 +487,24 @@
|
||||
return false;
|
||||
})();
|
||||
|
||||
// Build multi-video summary for debug reports
|
||||
const allVideos = [];
|
||||
const allVideoEls = document.querySelectorAll('video');
|
||||
for (let i = 0; i < allVideoEls.length; i++) {
|
||||
const v = allVideoEls[i];
|
||||
allVideos.push({
|
||||
index: i,
|
||||
width: v.videoWidth || v.offsetWidth || 0,
|
||||
height: v.videoHeight || v.offsetHeight || 0,
|
||||
muted: v.muted,
|
||||
paused: v.paused,
|
||||
duration: (v.duration && isFinite(v.duration)) ? Math.round(v.duration) : 0,
|
||||
readyState: v.readyState,
|
||||
src: (v.currentSrc || v.src || '').substring(0, 80),
|
||||
selected: v === video
|
||||
});
|
||||
}
|
||||
|
||||
if (video) {
|
||||
const dataAttributes = {};
|
||||
if (video.attributes) {
|
||||
@@ -522,7 +559,8 @@
|
||||
metadata,
|
||||
videoCount,
|
||||
inShadowDom,
|
||||
platform
|
||||
platform,
|
||||
allVideos
|
||||
});
|
||||
} else {
|
||||
sendResponse({
|
||||
@@ -530,6 +568,7 @@
|
||||
videoCount,
|
||||
inShadowDom,
|
||||
platform,
|
||||
allVideos,
|
||||
url: window.location.href,
|
||||
pageTitle: document.title,
|
||||
metadata: (navigator.mediaSession && navigator.mediaSession.metadata) ? {
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "AKTUALISIEREN",
|
||||
"BTN_REFRESH_TOOLTIP": "Die Liste der öffentlichen Räume aktualisieren",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aktualisiere...",
|
||||
"BTN_REFRESH_COOLDOWN": "WARTE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Die Raumliste kühlt ab. Versuche es in {seconds}s erneut.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aktualisiere öffentliche Räume. Nächste Aktualisierung in {seconds}s verfügbar.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktiver Raum",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Der Raum, mit dem du gerade verbunden bist",
|
||||
"ACTIVE_ROOM_NONE": "KEINER",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "REFRESH",
|
||||
"BTN_REFRESH_TOOLTIP": "Refresh the list of public rooms",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Refreshing...",
|
||||
"BTN_REFRESH_COOLDOWN": "WAIT {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Room list refresh is cooling down. Try again in {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Refreshing public rooms. Next refresh available in {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Active Room",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "The room you are currently connected to",
|
||||
"ACTIVE_ROOM_NONE": "NONE",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ACTUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualizar la lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualizando...",
|
||||
"BTN_REFRESH_COOLDOWN": "ESPERA {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La lista de salas está en espera. Inténtalo de nuevo en {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualizando salas públicas. Próxima actualización en {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala activa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La sala a la que estás conectado actualmente",
|
||||
"ACTIVE_ROOM_NONE": "NINGUNA",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ACTUALISER",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualiser la liste des salons publics",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualisation...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDRE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La liste des salons est en pause. Réessayez dans {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualisation des salons publics. Prochaine actualisation dans {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Salon actif",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Le salon auquel vous êtes actuellement connecté",
|
||||
"ACTIVE_ROOM_NONE": "AUCUN",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Atualizando...",
|
||||
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A lista de salas está em espera. Tente novamente em {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Atualizando salas públicas. Próxima atualização em {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala ativa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual você está conectado no momento",
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ОБНОВИТЬ",
|
||||
"BTN_REFRESH_TOOLTIP": "Обновить список публичных комнат",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Обновление...",
|
||||
"BTN_REFRESH_COOLDOWN": "ЖДИТЕ {seconds}с",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Список комнат временно ограничен. Повторите через {seconds}с.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Обновление публичных комнат. Следующее обновление через {seconds}с.",
|
||||
"LABEL_ACTIVE_ROOM": "Активная комната",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Комната, к которой вы сейчас подключены",
|
||||
"ACTIVE_ROOM_NONE": "НЕТ",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.5",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -66,21 +66,58 @@ let popupIntervals = [];
|
||||
let populateTabsToken = null;
|
||||
let errorToken = 0;
|
||||
let forceSyncDone = false;
|
||||
let connectionErrorTimer = null;
|
||||
let pendingConnectionErrorMsg = null;
|
||||
let roomListRefreshTimer = null;
|
||||
let roomListRefreshInterval = null;
|
||||
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
|
||||
|
||||
// --- Helpers ---
|
||||
function clearConnectionErrorTimer() {
|
||||
if (connectionErrorTimer) {
|
||||
clearTimeout(connectionErrorTimer);
|
||||
connectionErrorTimer = null;
|
||||
}
|
||||
pendingConnectionErrorMsg = null;
|
||||
}
|
||||
|
||||
function setRoomRefreshCooldown() {
|
||||
if (roomListRefreshTimer) clearTimeout(roomListRefreshTimer);
|
||||
if (roomListRefreshInterval) clearInterval(roomListRefreshInterval);
|
||||
const originalLabel = getMessage('BTN_REFRESH');
|
||||
const updateLabel = () => {
|
||||
const secondsLeft = Math.max(1, Math.ceil((cooldownEndsAt - Date.now()) / 1000));
|
||||
elements.refreshRooms.textContent = getMessage('BTN_REFRESH_COOLDOWN', { seconds: secondsLeft });
|
||||
elements.refreshRooms.title = getMessage('BTN_REFRESH_COOLDOWN_TOOLTIP', { seconds: secondsLeft });
|
||||
};
|
||||
|
||||
const cooldownEndsAt = Date.now() + ROOM_LIST_REFRESH_COOLDOWN_MS;
|
||||
elements.refreshRooms.disabled = true;
|
||||
updateLabel();
|
||||
|
||||
roomListRefreshInterval = setInterval(updateLabel, 250);
|
||||
roomListRefreshTimer = setTimeout(() => {
|
||||
elements.refreshRooms.disabled = false;
|
||||
elements.refreshRooms.textContent = originalLabel;
|
||||
elements.refreshRooms.title = getMessage('BTN_REFRESH_TOOLTIP');
|
||||
clearInterval(roomListRefreshInterval);
|
||||
roomListRefreshInterval = null;
|
||||
roomListRefreshTimer = null;
|
||||
}, ROOM_LIST_REFRESH_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']);
|
||||
|
||||
|
||||
let activeLang = data.locale;
|
||||
if (!activeLang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
|
||||
chrome.storage.sync.set({ locale: activeLang });
|
||||
}
|
||||
|
||||
|
||||
await loadLocale(activeLang);
|
||||
translateDOM();
|
||||
|
||||
@@ -148,8 +185,8 @@ async function init() {
|
||||
// Check for invite link on landing page
|
||||
checkInviteLink();
|
||||
|
||||
// Initial room list fetch
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
// Initialize public rooms placeholder
|
||||
renderEmpty(elements.publicRooms, 'rooms');
|
||||
|
||||
// Debug Info Refresh
|
||||
popupIntervals.push(setInterval(refreshDebugInfo, 2000));
|
||||
@@ -813,7 +850,7 @@ function updateRoomList(rooms) {
|
||||
function checkInviteLink() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (tab && tab.url && tab.url.includes(OFFICIAL_LANDING_PAGE_URL) && tab.url.includes('#join:')) {
|
||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
|
||||
try {
|
||||
const rawHash = tab.url.split('#join:')[1];
|
||||
if (!rawHash) return;
|
||||
@@ -952,7 +989,12 @@ function showToast(message, type = 'info', duration = 3000) {
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.textContent = message || '';
|
||||
toast.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
const delay = Math.max(0, duration - 600);
|
||||
toast.style.animation = `toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in ${delay}ms forwards`;
|
||||
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
@@ -981,6 +1023,7 @@ elements.roomId.addEventListener('input', () => {
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
clearConnectionErrorTimer();
|
||||
if (elements.joinBtn.disabled) return;
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
const isCreating = !roomIdInput;
|
||||
@@ -1040,6 +1083,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
clearConnectionErrorTimer();
|
||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' });
|
||||
elements.roomId.value = '';
|
||||
@@ -1080,10 +1124,13 @@ if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.refreshRooms.addEventListener('click', () => {
|
||||
if (elements.refreshRooms.disabled) return;
|
||||
setRoomRefreshCooldown();
|
||||
|
||||
elements.publicRooms.replaceChildren();
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;';
|
||||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING');
|
||||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING_COOLDOWN', { seconds: Math.ceil(ROOM_LIST_REFRESH_COOLDOWN_MS / 1000) });
|
||||
elements.publicRooms.appendChild(el);
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
});
|
||||
@@ -1310,7 +1357,25 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'LOG_UPDATE') {
|
||||
refreshLogs();
|
||||
if (msg.log && msg.log.type === 'error') {
|
||||
showError(msg.log.message);
|
||||
const errMsg = msg.log.message || '';
|
||||
const isConnErr = typeof errMsg === 'string' && (
|
||||
errMsg.toLowerCase().includes('connection') ||
|
||||
errMsg.toLowerCase().includes('websocket')
|
||||
);
|
||||
if (isConnErr) {
|
||||
pendingConnectionErrorMsg = msg.log.message;
|
||||
if (!connectionErrorTimer) {
|
||||
connectionErrorTimer = setTimeout(() => {
|
||||
if (pendingConnectionErrorMsg) {
|
||||
showError(pendingConnectionErrorMsg);
|
||||
}
|
||||
connectionErrorTimer = null;
|
||||
pendingConnectionErrorMsg = null;
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
showError(msg.log.message);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'ACTION_UPDATE') {
|
||||
const state = msg.state;
|
||||
@@ -1366,6 +1431,9 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
if (msg.status === 'connected') {
|
||||
clearConnectionErrorTimer();
|
||||
}
|
||||
if (msg.status !== 'reconnecting') {
|
||||
applyConnectionStatus(msg.status);
|
||||
reconnectSlowMode = false;
|
||||
@@ -1397,6 +1465,13 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
} else if (msg.type === 'ROOM_LIST') {
|
||||
updateRoomList(msg.rooms);
|
||||
} else if (msg.type === 'JOIN_STATUS') {
|
||||
if (joinBtnTimeout) {
|
||||
clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = null;
|
||||
}
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
|
||||
if (msg.success) {
|
||||
// Final confirmation of join from background
|
||||
chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
@@ -1462,6 +1537,24 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
if (vs.platform) lines.push(`- **Platform:** ${safe(vs.platform, '?')}`);
|
||||
lines.push(`- **Video Count:** ${safe(vs.videoCount, 0)} | **Shadow DOM:** ${vs.inShadowDom ? 'YES' : 'NO'}`);
|
||||
lines.push('');
|
||||
|
||||
// Multi-video overview
|
||||
const videos = Array.isArray(vs.allVideos) ? vs.allVideos : [];
|
||||
if (videos.length > 1) {
|
||||
lines.push('### All Videos on Page');
|
||||
lines.push('');
|
||||
lines.push('| # | Resolution | Muted | Paused | Ready | Duration | Selected |');
|
||||
lines.push('|---|------------|-------|--------|-------|----------|----------|');
|
||||
const rl = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
|
||||
for (const v of videos) {
|
||||
if (!v) continue;
|
||||
const sel = v.selected ? ' **\u2190 TARGET**' : '';
|
||||
const dim = `${safe(v.width, '?')}x${safe(v.height, '?')}`;
|
||||
const rs = (v.readyState != null && v.readyState >= 0 && v.readyState <= 4) ? rl[v.readyState] : '?';
|
||||
lines.push(`| ${safe(v.index, '?')} | ${dim} | ${safe(v.muted, '?')} | ${safe(v.paused, '?')} | ${rs} | ${safe(v.duration, 0)}s |${sel} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection ──
|
||||
@@ -1541,7 +1634,7 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
for (const h of recent) {
|
||||
if (!h) continue;
|
||||
const ts = safe(h.timestamp, '');
|
||||
const evt = safe(h.event, safe(h.type, '?'));
|
||||
const evt = safe(h.action, '?');
|
||||
const from = h.senderId ? ` (${h.senderId})` : (h.peerId ? ` (${h.peerId})` : '');
|
||||
const extra = h.detail ? ` \u2192 ${h.detail}` : '';
|
||||
lines.push(`[${ts}] ${evt}${from}${extra}`);
|
||||
@@ -1571,6 +1664,28 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
const original = elements.copyLogs.textContent;
|
||||
elements.copyLogs.textContent = getMessage('TOAST_LOGS_COPIED');
|
||||
setTimeout(() => { elements.copyLogs.textContent = original; }, 2000);
|
||||
|
||||
// Construct rich multiline toast summary
|
||||
const verStr = safe(status.version, '?');
|
||||
const logsCount = logs.length;
|
||||
const historyCount = history.length;
|
||||
const peersCount = Array.isArray(status.peers) ? status.peers.length : 0;
|
||||
|
||||
let summary = `📋 Debug Report Copied!\n`;
|
||||
summary += `• Version: v${verStr}\n`;
|
||||
summary += `• System Logs: ${logsCount} entries\n`;
|
||||
summary += `• Sync Actions: ${historyCount} captured\n`;
|
||||
if (status.roomId) {
|
||||
summary += `• Peers in Room: ${peersCount}\n`;
|
||||
}
|
||||
if (rawVideo && vs.found) {
|
||||
const stateStr = vs.paused === true ? 'Paused' : (vs.paused === false ? 'Playing' : 'Unknown');
|
||||
const timeStr = typeof vs.currentTime === 'number' ? `${Math.round(vs.currentTime)}s` : '?';
|
||||
summary += `• Video: ${stateStr} at ${timeStr}\n`;
|
||||
}
|
||||
summary += `Paste it in markdown to view!`;
|
||||
|
||||
showToast(summary, 'success', 5000);
|
||||
}).catch(() => {
|
||||
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
|
||||
});
|
||||
@@ -1735,6 +1850,14 @@ window.addEventListener('unload', () => {
|
||||
clearTimeout(forceSyncResetTimer);
|
||||
forceSyncResetTimer = null;
|
||||
}
|
||||
if (roomListRefreshTimer) {
|
||||
clearTimeout(roomListRefreshTimer);
|
||||
roomListRefreshTimer = null;
|
||||
}
|
||||
if (roomListRefreshInterval) {
|
||||
clearInterval(roomListRefreshInterval);
|
||||
roomListRefreshInterval = null;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.5",
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
@@ -1240,7 +1240,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1374,7 +1373,6 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -1897,7 +1895,6 @@
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.5",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
|
||||
const source = fs.readFileSync(contentPath, 'utf8');
|
||||
|
||||
function extractFunction(name, text) {
|
||||
const start = text.indexOf(`function ${name}`);
|
||||
assert.notStrictEqual(start, -1, `${name} not found`);
|
||||
|
||||
const bodyStart = text.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++;
|
||||
if (text[i] === '}') depth--;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function makeVideo(name, width, height, options = {}) {
|
||||
return {
|
||||
name,
|
||||
tagName: 'VIDEO',
|
||||
videoWidth: width,
|
||||
videoHeight: height,
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
|
||||
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
|
||||
|
||||
const shadowRoot = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [shadowPlayer];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const shadowHost = { shadowRoot };
|
||||
|
||||
const fakeDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [lightPreview];
|
||||
return [shadowHost];
|
||||
}
|
||||
};
|
||||
|
||||
const fnSource = extractFunction('findVideo', source);
|
||||
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
|
||||
|
||||
const selected = findVideo(fakeDocument);
|
||||
assert.strictEqual(
|
||||
selected,
|
||||
shadowPlayer,
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
console.log('content video finder tests passed');
|
||||
@@ -9,13 +9,47 @@ if (!fs.existsSync(enPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
// Verify SUPPORTED_LANGUAGES in extension/i18n.js matches JSON files
|
||||
const i18nPath = path.join(__dirname, '..', 'extension', 'i18n.js');
|
||||
try {
|
||||
if (fs.existsSync(i18nPath)) {
|
||||
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
|
||||
const langMatch = i18nContent.match(/export const SUPPORTED_LANGUAGES = \[(.*?)\];/);
|
||||
if (!langMatch) {
|
||||
hasError = true;
|
||||
console.error('❌ Could not parse SUPPORTED_LANGUAGES from extension/i18n.js');
|
||||
} else {
|
||||
const supportedLangs = langMatch[1].split(',').map(s => s.trim().replace(/['"]/g, ''));
|
||||
const fileLangs = fs.readdirSync(localesDir)
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.map(file => file.replace('.json', ''));
|
||||
|
||||
for (const lang of fileLangs) {
|
||||
if (!supportedLangs.includes(lang)) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${lang}.json exists in extension/locales but is missing from SUPPORTED_LANGUAGES in extension/i18n.js`);
|
||||
}
|
||||
}
|
||||
for (const lang of supportedLangs) {
|
||||
if (!fileLangs.includes(lang)) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${lang} is in SUPPORTED_LANGUAGES in extension/i18n.js but ${lang}.json is missing from extension/locales`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
hasError = true;
|
||||
console.error('❌ Failed to verify SUPPORTED_LANGUAGES synchronization:', err.message);
|
||||
}
|
||||
|
||||
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const enKeys = Object.keys(enDict);
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
|
||||
|
||||
let hasError = false;
|
||||
|
||||
console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
|
||||
|
||||
for (const file of localeFiles) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
|
||||
const popupPath = path.join(cwd(), 'extension', 'popup.js');
|
||||
const source = fs.readFileSync(popupPath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const ROOM_LIST_REFRESH_COOLDOWN_MS\s*=\s*11000;/,
|
||||
'popup should define an 11 second room-list refresh cooldown'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/elements\.refreshRooms\.disabled\s*=\s*true;/,
|
||||
'refresh button should be disabled while cooldown is active'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/setTimeout\(\(\)\s*=>\s*{\s*elements\.refreshRooms\.disabled\s*=\s*false;/s,
|
||||
'refresh button should be re-enabled after the cooldown'
|
||||
);
|
||||
|
||||
console.log('popup refresh cooldown tests passed');
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from '../server/ops.js';
|
||||
|
||||
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
|
||||
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
|
||||
|
||||
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
|
||||
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
|
||||
|
||||
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
|
||||
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
|
||||
|
||||
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
|
||||
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
|
||||
|
||||
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
|
||||
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
|
||||
assert.equal(
|
||||
isAdminMetricsTokenStrong('a'.repeat(32)),
|
||||
true,
|
||||
'admin token with at least 32 characters should be considered strong'
|
||||
);
|
||||
|
||||
const cooldowns = new Map();
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
|
||||
|
||||
const cache = new Map();
|
||||
let buildCalls = 0;
|
||||
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
|
||||
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
|
||||
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
|
||||
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
|
||||
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
|
||||
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
|
||||
|
||||
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
|
||||
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
|
||||
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
|
||||
|
||||
const basicHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: false,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(basicHealth).sort(),
|
||||
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
|
||||
'basic health should not expose extended metrics'
|
||||
);
|
||||
|
||||
const adminHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: true,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
});
|
||||
|
||||
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
|
||||
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
|
||||
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
|
||||
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
|
||||
assert.deepEqual(
|
||||
adminHealth.rateLimitEntries,
|
||||
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
|
||||
'admin metrics should expose aggregate rate-limit map sizes'
|
||||
);
|
||||
|
||||
console.log('server ops tests passed');
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
healthResponseCache,
|
||||
httpServer,
|
||||
rooms,
|
||||
startServer,
|
||||
stopServerForTests
|
||||
} from '../server/index.js';
|
||||
|
||||
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
|
||||
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
|
||||
|
||||
function url(path) {
|
||||
const address = httpServer.address();
|
||||
return `http://127.0.0.1:${address.port}${path}`;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
return fetch(url(path), {
|
||||
...options,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await startServer(0, '127.0.0.1');
|
||||
|
||||
let res = await request('/');
|
||||
assert.equal(res.status, 200, 'root health endpoint should respond');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'root response should disable HTTP caching');
|
||||
assert.deepEqual(await res.json(), { status: 'online', service: 'KoalaSync Relay' });
|
||||
|
||||
res = await request('/health');
|
||||
assert.equal(res.status, 200, 'basic health endpoint should respond');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'basic health response should disable HTTP caching');
|
||||
const basicHealth = await res.json();
|
||||
assert.equal(basicHealth.status, 'ok', 'basic health should report ok');
|
||||
assert.equal(basicHealth.rooms, 0, 'basic health should include room count');
|
||||
assert.equal('peers' in basicHealth, false, 'basic health should not expose admin metrics');
|
||||
assert.equal('memory' in basicHealth, false, 'basic health should not expose memory metrics');
|
||||
|
||||
rooms.set('route-test-room', {
|
||||
peers: new Set(['socket-a', 'socket-b']),
|
||||
peerData: new Map(),
|
||||
peerIds: new Map(),
|
||||
activeLobby: { expectedTitle: 'Episode 1', initiatorPeerId: 'peer-a', readyPeers: ['peer-a'] }
|
||||
});
|
||||
healthResponseCache.clear();
|
||||
healthCounts.clear();
|
||||
|
||||
res = await request('/health', {
|
||||
headers: { authorization: `Bearer ${adminToken}`, 'x-forwarded-for': '203.0.113.20' }
|
||||
});
|
||||
assert.equal(res.status, 200, 'authorized admin health endpoint should respond');
|
||||
const adminHealth = await res.json();
|
||||
assert.equal(adminHealth.rooms, 1, 'admin health should include room count');
|
||||
assert.equal(adminHealth.peers, 2, 'admin health should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin health should include aggregate lobby count');
|
||||
assert.equal(typeof adminHealth.memory?.rss, 'number', 'admin health should include aggregate memory metrics');
|
||||
assert.equal('route-test-room' in adminHealth, false, 'admin health should not expose room identifiers');
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
for (let i = 0; i < ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE; i++) {
|
||||
res = await request('/health', {
|
||||
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
|
||||
});
|
||||
assert.equal(res.status, 200, `wrong admin bearer attempt ${i + 1} should still return basic health`);
|
||||
}
|
||||
res = await request('/health', {
|
||||
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
|
||||
});
|
||||
assert.equal(res.status, 429, 'wrong admin bearer attempts should be throttled after the limit');
|
||||
|
||||
healthCounts.clear();
|
||||
for (let i = 0; i < HEALTH_RATE_LIMIT_PER_MINUTE; i++) {
|
||||
const path = i % 2 === 0 ? '/' : '/health';
|
||||
res = await request(path, { headers: { 'x-forwarded-for': '203.0.113.40' } });
|
||||
assert.equal(res.status, 200, `shared health request ${i + 1} should be allowed`);
|
||||
}
|
||||
res = await request('/', { headers: { 'x-forwarded-for': '203.0.113.40' } });
|
||||
assert.equal(res.status, 429, 'root and health should share the public health rate limit');
|
||||
|
||||
console.log('server route tests passed');
|
||||
} finally {
|
||||
await stopServerForTests();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
||||
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
||||
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
||||
}],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.js']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['server syntax index', 'node', ['-c', 'server/index.js']],
|
||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||
['content syntax', 'node', ['-c', 'extension/content.js']],
|
||||
['popup syntax', 'node', ['-c', 'extension/popup.js']],
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.js']],
|
||||
['lint', 'npm', ['run', 'lint']],
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
|
||||
['extension build', 'npm', ['run', 'build:extension']],
|
||||
['website build', 'node', ['website/build.js']]
|
||||
];
|
||||
|
||||
function runCheck([label, command, args, options = {}]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`\n==> ${label}`);
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
env: { ...process.env, ...(options.env || {}) },
|
||||
stdio: 'inherit'
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${label} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const check of checks) {
|
||||
await runCheck(check);
|
||||
}
|
||||
|
||||
console.log('\nRelease verification passed');
|
||||
@@ -1,4 +1,7 @@
|
||||
PORT=3000
|
||||
MIN_VERSION=1.0.0
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
MAX_PEERS_PER_ROOM=25
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=
|
||||
|
||||
@@ -14,10 +14,34 @@ Copy `.env.example` to `.env` and configure your settings.
|
||||
```bash
|
||||
PORT=3000
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
MAX_PEERS_PER_ROOM=25
|
||||
MIN_VERSION=1.0.0
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=
|
||||
```
|
||||
|
||||
### Health & Metrics
|
||||
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
|
||||
|
||||
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. Wrong admin bearer attempts are separately limited to 5 requests per minute per client IP. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
|
||||
|
||||
Use a long random `ADMIN_METRICS_TOKEN` of at least 32 characters. Shorter configured tokens still work, but the server logs a startup warning.
|
||||
|
||||
Generate a token with one of these commands:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
# or
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
For Docker Compose deployments, set the generated value in `server/.env`:
|
||||
```bash
|
||||
ADMIN_METRICS_TOKEN=replace-with-a-long-random-token
|
||||
```
|
||||
|
||||
When polling `/health` from Prometheus, Uptime Kuma, cron, or a load balancer, keep the interval comfortably below the public limit of 10 requests per minute per client IP. A 30-60 second interval is recommended for routine monitoring. Use the admin bearer token only from trusted monitoring hosts, and keep the Node server private behind Caddy or another trusted reverse proxy because IP-based limits depend on the configured proxy boundary.
|
||||
|
||||
### Docker (Recommended)
|
||||
The server is available as a pre-built image on GHCR.
|
||||
```bash
|
||||
@@ -38,6 +62,9 @@ npm start
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
|
||||
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
|
||||
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
|
||||
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
|
||||
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
|
||||
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -15,19 +23,36 @@ function hashPassword(password) {
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 50;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
|
||||
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
||||
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
|
||||
const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
|
||||
|
||||
const app = express();
|
||||
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
||||
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
|
||||
}
|
||||
|
||||
export const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
export const healthResponseCache = new Map();
|
||||
|
||||
// Health Check with Rate Limiting
|
||||
app.get('/', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests. Try again later.' });
|
||||
}
|
||||
res.json({ status: 'online', service: 'KoalaSync Relay' });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(getCachedPayload(
|
||||
healthResponseCache,
|
||||
'root',
|
||||
HEALTH_RESPONSE_CACHE_TTL_MS,
|
||||
() => ({ status: 'online', service: 'KoalaSync Relay' })
|
||||
));
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
@@ -35,19 +60,37 @@ app.get('/health', (req, res) => {
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
rooms: rooms.size,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
const authHeader = req.get('authorization');
|
||||
const includeMetrics = isAdminMetricsAuthorized(authHeader, ADMIN_METRICS_TOKEN);
|
||||
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(getCachedPayload(
|
||||
healthResponseCache,
|
||||
includeMetrics ? 'health-admin' : 'health-basic',
|
||||
HEALTH_RESPONSE_CACHE_TTL_MS,
|
||||
() => buildHealthPayload({
|
||||
rooms,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
includeMetrics,
|
||||
uptime: process.uptime(),
|
||||
rateLimitSizes: {
|
||||
connections: connectionCounts.size,
|
||||
events: eventCounts.size,
|
||||
health: healthCounts.size,
|
||||
adminMetricsAuth: adminMetricsAuthCounts.size,
|
||||
authFailures: failedAuthAttempts.size,
|
||||
roomList: roomListCooldowns.size
|
||||
}
|
||||
})
|
||||
));
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
export const httpServer = createServer(app);
|
||||
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
export const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
|
||||
@@ -67,7 +110,7 @@ const io = new Server(httpServer, {
|
||||
/**
|
||||
* In-memory storage
|
||||
*/
|
||||
const rooms = new Map();
|
||||
export const rooms = new Map();
|
||||
const socketToRoom = new Map();
|
||||
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
@@ -78,7 +121,7 @@ function log(type, message, details = '') {
|
||||
}
|
||||
|
||||
// Rate Limiting & Security
|
||||
const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
|
||||
function checkAuthRate(ip, roomId) {
|
||||
@@ -128,7 +171,7 @@ function recordAuthFailure(ip, roomId) {
|
||||
}
|
||||
|
||||
// Periodically clean up old auth failure records (every 15 minutes)
|
||||
setInterval(() => {
|
||||
const authFailureCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
@@ -137,11 +180,13 @@ setInterval(() => {
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
setInterval(() => {
|
||||
const rateLimitCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
@@ -158,6 +203,16 @@ setInterval(() => {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
adminMetricsAuthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId] of roomListCooldowns.entries()) {
|
||||
if (!io.sockets.sockets.has(socketId)) {
|
||||
roomListCooldowns.delete(socketId);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function checkConnectionRate(ip) {
|
||||
@@ -184,7 +239,16 @@ function checkHealthRate(ip) {
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
return entry.count <= 60;
|
||||
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
|
||||
}
|
||||
|
||||
function checkAdminMetricsAuthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
adminMetricsAuthCounts.set(ip, entry);
|
||||
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -503,7 +567,7 @@ io.on('connection', (socket) => {
|
||||
mediaTitle: clamp(data.mediaTitle, 100),
|
||||
volume: clampNum(data.volume, 0, 1),
|
||||
muted: validBool(data.muted),
|
||||
peerId: typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : undefined,
|
||||
peerId: mapping.peerId,
|
||||
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
||||
expectedTitle: clamp(data.expectedTitle, 100),
|
||||
title: clamp(data.title, 100),
|
||||
@@ -541,6 +605,10 @@ io.on('connection', (socket) => {
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
|
||||
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
|
||||
return;
|
||||
}
|
||||
const list = Array.from(rooms.entries()).map(([id, r]) => ({
|
||||
id,
|
||||
peerCount: r.peers.size,
|
||||
@@ -584,6 +652,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
@@ -595,7 +664,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||
setInterval(() => {
|
||||
const roomCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
@@ -632,9 +701,29 @@ setInterval(() => {
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
|
||||
});
|
||||
export function startServer(port = PORT, host) {
|
||||
if (httpServer.listening) return Promise.resolve(httpServer);
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (err) => {
|
||||
httpServer.off('listening', onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off('error', onError);
|
||||
const address = httpServer.address();
|
||||
const actualPort = address && typeof address === 'object' ? address.port : port;
|
||||
log('SERVER', `KoalaSync Relay running on port ${actualPort}`);
|
||||
resolve(httpServer);
|
||||
};
|
||||
httpServer.once('error', onError);
|
||||
httpServer.once('listening', onListening);
|
||||
if (host) {
|
||||
httpServer.listen(port, host);
|
||||
} else {
|
||||
httpServer.listen(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- M-4: Graceful Shutdown ---
|
||||
function gracefulShutdown(signal) {
|
||||
@@ -653,15 +742,44 @@ function gracefulShutdown(signal) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
export async function stopServerForTests() {
|
||||
clearInterval(authFailureCleanupInterval);
|
||||
clearInterval(rateLimitCleanupInterval);
|
||||
clearInterval(roomCleanupInterval);
|
||||
rooms.clear();
|
||||
socketToRoom.clear();
|
||||
peerToSocket.clear();
|
||||
roomCreationLocks.clear();
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
if (!httpServer.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
httpServer.close((err) => err ? reject(err) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
if (isMainModule) {
|
||||
startServer(PORT);
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
|
||||
const lastAllowedAt = cooldowns.get(key) || 0;
|
||||
if (now - lastAllowedAt < windowMs) {
|
||||
return false;
|
||||
}
|
||||
cooldowns.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCachedPayload(cache, key, ttlMs, buildPayload, now = Date.now()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached && now - cached.createdAt < ttlMs) {
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
cache.set(key, { createdAt: now, payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function isAdminMetricsAuthorized(authHeader, adminToken) {
|
||||
if (!adminToken || typeof adminToken !== 'string') return false;
|
||||
if (!authHeader || typeof authHeader !== 'string') return false;
|
||||
|
||||
const prefix = 'Bearer ';
|
||||
if (!authHeader.startsWith(prefix)) return false;
|
||||
|
||||
const provided = authHeader.slice(prefix.length);
|
||||
if (!provided) return false;
|
||||
|
||||
const expectedBuffer = Buffer.from(adminToken);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
|
||||
return !adminToken || (typeof adminToken === 'string' && adminToken.length >= minLength);
|
||||
}
|
||||
|
||||
export function buildHealthPayload({
|
||||
rooms,
|
||||
connections,
|
||||
includeMetrics = false,
|
||||
now = Date.now(),
|
||||
uptime = 0,
|
||||
memoryUsage = () => process.memoryUsage(),
|
||||
rateLimitSizes = {}
|
||||
}) {
|
||||
const payload = {
|
||||
status: 'ok',
|
||||
uptime,
|
||||
rooms: rooms.size,
|
||||
connections,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
if (!includeMetrics) return payload;
|
||||
|
||||
const roomValues = Array.from(rooms.values());
|
||||
const roomSizes = roomValues.map(room => room.peers?.size || 0);
|
||||
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
|
||||
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
|
||||
const avgPeersPerRoom = roomSizes.length > 0
|
||||
? Math.round((peers / roomSizes.length) * 100) / 100
|
||||
: 0;
|
||||
const mem = memoryUsage();
|
||||
|
||||
return {
|
||||
...payload,
|
||||
peers,
|
||||
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
|
||||
avgPeersPerRoom,
|
||||
maxPeersInRoom,
|
||||
rateLimitEntries: {
|
||||
connections: rateLimitSizes.connections || 0,
|
||||
events: rateLimitSizes.events || 0,
|
||||
health: rateLimitSizes.health || 0,
|
||||
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
|
||||
authFailures: rateLimitSizes.authFailures || 0,
|
||||
roomList: rateLimitSizes.roomList || 0
|
||||
},
|
||||
memory: {
|
||||
rss: mem.rss,
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
@@ -70,15 +69,6 @@
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -266,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
|
||||
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
|
||||
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -280,7 +270,7 @@
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
@@ -749,9 +739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -951,13 +941,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
|
||||
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
@@ -1079,9 +1069,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
|
||||
@@ -67,7 +67,6 @@ export const BLACKLIST_DOMAINS = [
|
||||
|
||||
// Productivity & Project Management
|
||||
'atlassian.net',
|
||||
'jira',
|
||||
'trello.com',
|
||||
'notion.so',
|
||||
'monday.com',
|
||||
@@ -168,7 +167,7 @@ export const BLACKLIST_DOMAINS = [
|
||||
// Games & Idle Sites
|
||||
'milkywayidle.com',
|
||||
'melvoridle.com',
|
||||
'cookieclicker.',
|
||||
'orteil.dashnet.org',
|
||||
'clickerheroes.com',
|
||||
'kongregate.com',
|
||||
'armorgames.com',
|
||||
|
||||
@@ -310,7 +310,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const updateDynamicVersion = async () => {
|
||||
try {
|
||||
const versionPath = document.documentElement.lang === 'de' ? '../version.json' : 'version.json';
|
||||
const versionPath = document.documentElement.lang === 'en' ? 'version.json' : '../version.json';
|
||||
const response = await fetch(versionPath);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
@@ -555,7 +555,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const user = this.getAttribute('data-user');
|
||||
const domain = this.getAttribute('data-domain');
|
||||
if (user && domain) {
|
||||
this.innerHTML = `${user}@${domain}`;
|
||||
this.textContent = `${user}@${domain}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,11 +119,16 @@ async function compile() {
|
||||
const localesDir = path.join(websiteDir, 'locales');
|
||||
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
|
||||
// Read version for build-time injection (SEO: crawlers see real version)
|
||||
const versionJson = JSON.parse(fs.readFileSync(path.join(websiteDir, 'version.json'), 'utf8'));
|
||||
const buildVersion = versionJson.version || '?';
|
||||
|
||||
const englishHtml = {}; // track for join-page cross-ref
|
||||
|
||||
function compilePage(locale, assetPath, lang) {
|
||||
let compiled = templateContent;
|
||||
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
|
||||
compiled = compiled.replace(/\{\{VERSION\}\}/g, buildVersion);
|
||||
languages.forEach(l => {
|
||||
compiled = compiled.replace(new RegExp(`\\{\\{SELECTED_${l.toUpperCase()}\\}\\}`, 'g'), l === lang ? 'selected' : '');
|
||||
});
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
@@ -50,6 +49,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="globe-icon"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -62,6 +63,7 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
@@ -50,6 +49,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="globe-icon"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -62,6 +63,7 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
<img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="64" height="64">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
@@ -54,6 +53,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="globe-icon"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -66,6 +67,7 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"COMP_FEAT_5_DESC": "Unterstützte Webseiten und Player-Kompatibilität.",
|
||||
"COMP_FEAT_5_KOALA": "Fast jedes HTML5-Video",
|
||||
"COMP_FEAT_5_TELE": "Nur unterstützte Seiten",
|
||||
"COMP_FEAT_6_NAME": "Lokalisierung / Übersetzung",
|
||||
"COMP_FEAT_6_DESC": "Verfügbare Oberflächensprachen und Übersetzungsunterstützung.",
|
||||
"COMP_FEAT_6_KOALA": "6 Sprachen (wachsend)",
|
||||
"COMP_FEAT_6_TELE": "Nur Englisch",
|
||||
"COMP_FOOTNOTE_1": "Stand des Vergleichs: Mai 2026.",
|
||||
"COMP_FOOTNOTE_2": "Details zu Preisen und unterstützten Netzwerken von Teleparty Premium:",
|
||||
"COMP_FOOTNOTE_3": "Datenschutzerklärung und Tracker-Erfassung von Teleparty:",
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"COMP_FEAT_5_DESC": "Supported websites and player compatibility.",
|
||||
"COMP_FEAT_5_KOALA": "Almost any HTML5 Video",
|
||||
"COMP_FEAT_5_TELE": "Supported sites only",
|
||||
"COMP_FEAT_6_NAME": "Localization / Translation",
|
||||
"COMP_FEAT_6_DESC": "Available interface languages and translation support.",
|
||||
"COMP_FEAT_6_KOALA": "6 Languages (and growing)",
|
||||
"COMP_FEAT_6_TELE": "English only",
|
||||
"COMP_FOOTNOTE_1": "Comparison state: May 2026.",
|
||||
"COMP_FOOTNOTE_2": "Official Teleparty Premium pricing and supported networks details:",
|
||||
"COMP_FOOTNOTE_3": "Teleparty privacy policies and tracking data collection:",
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"COMP_FEAT_5_DESC": "Sitios web compatibles y compatibilidad de reproductores.",
|
||||
"COMP_FEAT_5_KOALA": "Casi cualquier video HTML5",
|
||||
"COMP_FEAT_5_TELE": "Solo sitios compatibles",
|
||||
"COMP_FEAT_6_NAME": "Localización / Traducción",
|
||||
"COMP_FEAT_6_DESC": "Idiomas de interfaz disponibles y soporte de traducción.",
|
||||
"COMP_FEAT_6_KOALA": "6 idiomas (y creciendo)",
|
||||
"COMP_FEAT_6_TELE": "Solo inglés",
|
||||
"COMP_FOOTNOTE_1": "Estado de la comparación: mayo de 2026.",
|
||||
"COMP_FOOTNOTE_2": "Detalles oficiales de precios de Teleparty Premium y redes compatibles:",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidad y recopilación de datos de seguimiento de Teleparty:",
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"COMP_FEAT_5_DESC": "Sites web pris en charge et compatibilité des lecteurs.",
|
||||
"COMP_FEAT_5_KOALA": "Presque toutes les vidéos HTML5",
|
||||
"COMP_FEAT_5_TELE": "Sites pris en charge uniquement",
|
||||
"COMP_FEAT_6_NAME": "Localisation / Traduction",
|
||||
"COMP_FEAT_6_DESC": "Langues d'interface disponibles et support de traduction.",
|
||||
"COMP_FEAT_6_KOALA": "6 langues (et plus à venir)",
|
||||
"COMP_FEAT_6_TELE": "Anglais uniquement",
|
||||
"COMP_FOOTNOTE_1": "État de la comparaison : mai 2026.",
|
||||
"COMP_FOOTNOTE_2": "Détails sur les tarifs officiels de Teleparty Premium et les réseaux pris en charge :",
|
||||
"COMP_FOOTNOTE_3": "Politiques de confidentialité et collecte de données de suivi de Teleparty :",
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"COMP_FEAT_5_DESC": "Sites suportados e compatibilidade do player.",
|
||||
"COMP_FEAT_5_KOALA": "Quase qualquer vídeo HTML5",
|
||||
"COMP_FEAT_5_TELE": "Apenas sites suportados",
|
||||
"COMP_FEAT_6_NAME": "Localização / Tradução",
|
||||
"COMP_FEAT_6_DESC": "Idiomas de interface disponíveis e suporte a tradução.",
|
||||
"COMP_FEAT_6_KOALA": "6 idiomas (e crescendo)",
|
||||
"COMP_FEAT_6_TELE": "Apenas inglês",
|
||||
"COMP_FOOTNOTE_1": "Estado da comparação: maio de 2026.",
|
||||
"COMP_FOOTNOTE_2": "Preços oficiais do Teleparty Premium e detalhes das redes suportadas:",
|
||||
"COMP_FOOTNOTE_3": "Políticas de privacidade do Teleparty e coleta de dados de rastreamento:",
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"COMP_FEAT_5_DESC": "Поддерживаемые веб-ресурсы и совместимость плееров.",
|
||||
"COMP_FEAT_5_KOALA": "Почти любое HTML5 видео",
|
||||
"COMP_FEAT_5_TELE": "Только поддерживаемые сайты",
|
||||
"COMP_FEAT_6_NAME": "Локализация / Перевод",
|
||||
"COMP_FEAT_6_DESC": "Доступные языки интерфейса и поддержка перевода.",
|
||||
"COMP_FEAT_6_KOALA": "6 языков (и расширяется)",
|
||||
"COMP_FEAT_6_TELE": "Только английский",
|
||||
"COMP_FOOTNOTE_1": "Состояние сравнения: май 2026.",
|
||||
"COMP_FOOTNOTE_2": "Официальные цены Teleparty Premium и поддерживаемые сети:",
|
||||
"COMP_FOOTNOTE_3": "Политика конфиденциальности Teleparty и сбор трекеров:",
|
||||
|
||||
@@ -131,7 +131,6 @@ nav {
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -182,6 +181,7 @@ nav {
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
@@ -195,6 +195,14 @@ nav {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Hero Section --- */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
@@ -314,6 +322,14 @@ nav {
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.version-badge:hover {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* --- Interactive CSS Extension Mockup --- */
|
||||
@@ -1889,12 +1905,44 @@ footer {
|
||||
padding: 1rem 2rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
margin-left: 0;
|
||||
}
|
||||
.nav-links.open {
|
||||
display: flex;
|
||||
}
|
||||
.nav-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
.hamburger {
|
||||
display: flex;
|
||||
display: none !important;
|
||||
}
|
||||
nav .container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.logo-area {
|
||||
gap: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.logo-area img {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
.lang-select-container {
|
||||
padding: 4px 10px 4px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
.lang-select-container .globe-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.lang-dropdown {
|
||||
font-size: 0.7rem;
|
||||
padding: 0 6px 0 0;
|
||||
}
|
||||
.lang-select-container .chevron-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.legal-card {
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>{{NAV_FEATURES}}</span></a>
|
||||
<a href="#how-it-works"><span>{{NAV_HOW_IT_WORKS}}</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="globe-icon"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>{{HERO_TITLE}}</h1>
|
||||
<p class="hero-subtitle" data-reveal>{{HERO_SUBTITLE}}</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo">KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" style="width: 12px; height: 12px; display: block;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">{{VERSION}}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">{{VERSION}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>{{COMP_FEAT_6_NAME}}</span></strong>
|
||||
<span class="feat-desc"><span>{{COMP_FEAT_6_DESC}}</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>{{COMP_FEAT_6_KOALA}}</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>{{COMP_FEAT_6_TELE}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14">
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">{{VERSION}}</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.9.3",
|
||||
"date": "2026-05-30T00:01:24Z"
|
||||
"version": "2.0.5",
|
||||
"date": "2026-06-03T09:32:00Z"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Datenschutz / Privacy Policy | KoalaSync</title>
|
||||
<link rel="preload" href="style.23314305.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.23314305.min.css">
|
||||
<link rel="preload" href="style.3ca56f45.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
|
||||
<meta name="robots" content="noindex">
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" style="width:16px;height:16px;display:block" viewBox="0 0 24 24"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>
|
||||
@@ -50,6 +49,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -62,6 +63,7 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -216,6 +218,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<title>KoalaSync | Netflix, YouTube & jedes Video mit Freunden synchronisieren</title>
|
||||
<meta name="description" content="Schaue Netflix, YouTube, Twitch und jedes HTML5-Video synchron mit Freunden. Kostenlose, quelloffene Browser-Erweiterung. Keine Anmeldung erforderlich.">
|
||||
|
||||
<link rel="preload" href="../style.23314305.min.css" as="style">
|
||||
<link rel="preload" href="../style.3ca56f45.min.css" as="style">
|
||||
<link rel="preload" as="image" href="../assets/LookDownKoala.webp" imagesrcset="../assets/LookDownKoala-1x.webp 90w, ../assets/LookDownKoala.webp 205w" imagesizes="90px" fetchpriority="high">
|
||||
<link rel="stylesheet" href="../style.23314305.min.css">
|
||||
<link rel="stylesheet" href="../style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../assets/icon-192x192.png">
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>Funktionen</span></a>
|
||||
<a href="#how-it-works"><span>So funktioniert's</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>Gemeinsam schauen.<br>Perfekt synchron.</h1>
|
||||
<p class="hero-subtitle" data-reveal>Dein Kino-Abend auf Distanz. Keine Lags, keine Anmeldung. Einfach Link teilen und zusammen schauen.</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">2.0.5</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>Lokalisierung / Übersetzung</span></strong>
|
||||
<span class="feat-desc"><span>Verfügbare Oberflächensprachen und Übersetzungsunterstützung.</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>6 Sprachen (wachsend)</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>Nur Englisch</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">2.0.5</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
@@ -1177,6 +1189,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.e6679b8d.min.js"></script>
|
||||
<script src="../app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<title>KoalaSync | Sincroniza Netflix, YouTube y cualquier video con amigos</title>
|
||||
<meta name="description" content="Mira Netflix, YouTube, Twitch y cualquier video HTML5 en perfecta sincronización con amigos. Extensión de navegador gratuita y de código abierto. Sin registro.">
|
||||
|
||||
<link rel="preload" href="../style.23314305.min.css" as="style">
|
||||
<link rel="preload" href="../style.3ca56f45.min.css" as="style">
|
||||
<link rel="preload" as="image" href="../assets/LookDownKoala.webp" imagesrcset="../assets/LookDownKoala-1x.webp 90w, ../assets/LookDownKoala.webp 205w" imagesizes="90px" fetchpriority="high">
|
||||
<link rel="stylesheet" href="../style.23314305.min.css">
|
||||
<link rel="stylesheet" href="../style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../assets/icon-192x192.png">
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>Características</span></a>
|
||||
<a href="#how-it-works"><span>Cómo funciona</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>Miren juntos.<br>Sincronización perfecta.</h1>
|
||||
<p class="hero-subtitle" data-reveal>Tu noche de películas a distancia sin retrasos. Sin registro ni recopilación de datos. Solo comparte un enlace y miren juntos.</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">2.0.5</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>Localización / Traducción</span></strong>
|
||||
<span class="feat-desc"><span>Idiomas de interfaz disponibles y soporte de traducción.</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>6 idiomas (y creciendo)</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>Solo inglés</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">2.0.5</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
@@ -1177,6 +1189,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.e6679b8d.min.js"></script>
|
||||
<script src="../app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<title>KoalaSync | Synchronisez Netflix, YouTube et n'importe quelle vidéo avec vos amis</title>
|
||||
<meta name="description" content="Regardez Netflix, YouTube, Twitch et des vidéos HTML5 en synchro avec vos amis. Extension de navigateur gratuite et open-source. Aucune inscription requise.">
|
||||
|
||||
<link rel="preload" href="../style.23314305.min.css" as="style">
|
||||
<link rel="preload" href="../style.3ca56f45.min.css" as="style">
|
||||
<link rel="preload" as="image" href="../assets/LookDownKoala.webp" imagesrcset="../assets/LookDownKoala-1x.webp 90w, ../assets/LookDownKoala.webp 205w" imagesizes="90px" fetchpriority="high">
|
||||
<link rel="stylesheet" href="../style.23314305.min.css">
|
||||
<link rel="stylesheet" href="../style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../assets/icon-192x192.png">
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>Fonctionnalités</span></a>
|
||||
<a href="#how-it-works"><span>Comment ça marche</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>Regardez ensemble.<br>Synchronisation parfaite.</h1>
|
||||
<p class="hero-subtitle" data-reveal>Votre soirée cinéma à distance sans décalage. Pas d'inscription, pas de collecte de données. Partagez simplement un lien et regardez ensemble.</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">2.0.5</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>Localisation / Traduction</span></strong>
|
||||
<span class="feat-desc"><span>Langues d'interface disponibles et support de traduction.</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>6 langues (et plus à venir)</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>Anglais uniquement</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">2.0.5</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
@@ -1177,6 +1189,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.e6679b8d.min.js"></script>
|
||||
<script src="../app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum / Legal Notice | KoalaSync</title>
|
||||
<link rel="preload" href="style.23314305.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.23314305.min.css">
|
||||
<link rel="preload" href="style.3ca56f45.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
|
||||
<meta name="robots" content="noindex">
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" style="width:16px;height:16px;display:block" viewBox="0 0 24 24"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>
|
||||
@@ -50,6 +49,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -62,6 +63,7 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -190,6 +192,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<title>KoalaSync | Sync Netflix, YouTube & Any Video with Friends</title>
|
||||
<meta name="description" content="Watch Netflix, YouTube, Twitch & any HTML5 video in perfect sync with friends. Free, open-source browser extension for Chrome and Firefox. No sign-up needed.">
|
||||
|
||||
<link rel="preload" href="style.23314305.min.css" as="style">
|
||||
<link rel="preload" href="style.3ca56f45.min.css" as="style">
|
||||
<link rel="preload" as="image" href="assets/LookDownKoala.webp" imagesrcset="assets/LookDownKoala-1x.webp 90w, assets/LookDownKoala.webp 205w" imagesizes="90px" fetchpriority="high">
|
||||
<link rel="stylesheet" href="style.23314305.min.css">
|
||||
<link rel="stylesheet" href="style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/icon-192x192.png">
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>Features</span></a>
|
||||
<a href="#how-it-works"><span>How it works</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>Watch Together.<br>Sync Perfectly.</h1>
|
||||
<p class="hero-subtitle" data-reveal>Your remote movie night without lags. No registration, no data collection. Just share a link and watch together.</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">2.0.5</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>Localization / Translation</span></strong>
|
||||
<span class="feat-desc"><span>Available interface languages and translation support.</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>6 Languages (and growing)</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>English only</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">2.0.5</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
@@ -1177,6 +1189,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Join Room | KoalaSync</title>
|
||||
<link rel="preload" href="style.23314305.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.23314305.min.css">
|
||||
<link rel="preload" href="style.3ca56f45.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/icon-192x192.png">
|
||||
@@ -44,7 +44,6 @@
|
||||
<picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="64" height="64"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" style="width:16px;height:16px;display:block" viewBox="0 0 24 24"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>
|
||||
@@ -54,6 +53,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -66,6 +67,7 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -119,6 +121,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<title>KoalaSync | Sincronize Netflix, YouTube e qualquer vídeo com amigos</title>
|
||||
<meta name="description" content="Assista Netflix, YouTube, Twitch e qualquer vídeo HTML5 em perfeita sincronia com amigos. Extensão de navegador gratuita e de código aberto. Sem registro.">
|
||||
|
||||
<link rel="preload" href="../style.23314305.min.css" as="style">
|
||||
<link rel="preload" href="../style.3ca56f45.min.css" as="style">
|
||||
<link rel="preload" as="image" href="../assets/LookDownKoala.webp" imagesrcset="../assets/LookDownKoala-1x.webp 90w, ../assets/LookDownKoala.webp 205w" imagesizes="90px" fetchpriority="high">
|
||||
<link rel="stylesheet" href="../style.23314305.min.css">
|
||||
<link rel="stylesheet" href="../style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../assets/icon-192x192.png">
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>Recursos</span></a>
|
||||
<a href="#how-it-works"><span>Como funciona</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>Assistam juntos.<br>Sincronia perfeita.</h1>
|
||||
<p class="hero-subtitle" data-reveal>Sua noite de cinema à distância sem atrasos. Sem registro, sem coleta de dados. Apenas compartilhe o link e assistam juntos.</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">2.0.5</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>Localização / Tradução</span></strong>
|
||||
<span class="feat-desc"><span>Idiomas de interface disponíveis e suporte a tradução.</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>6 idiomas (e crescendo)</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>Apenas inglês</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">2.0.5</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
@@ -1177,6 +1189,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.e6679b8d.min.js"></script>
|
||||
<script src="../app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<title>KoalaSync | Синхронизация Netflix, YouTube и любого видео с друзьями</title>
|
||||
<meta name="description" content="Смотрите Netflix, YouTube, Twitch и любые HTML5-видео в синхронизации с друзьями. Бесплатное расширение браузера с открытым кодом. Регистрация не требуется.">
|
||||
|
||||
<link rel="preload" href="../style.23314305.min.css" as="style">
|
||||
<link rel="preload" href="../style.3ca56f45.min.css" as="style">
|
||||
<link rel="preload" as="image" href="../assets/LookDownKoala.webp" imagesrcset="../assets/LookDownKoala-1x.webp 90w, ../assets/LookDownKoala.webp 205w" imagesizes="90px" fetchpriority="high">
|
||||
<link rel="stylesheet" href="../style.23314305.min.css">
|
||||
<link rel="stylesheet" href="../style.3ca56f45.min.css">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../assets/icon-192x192.png">
|
||||
@@ -185,9 +185,6 @@
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
|
||||
<a href="#features"><span>Функции</span></a>
|
||||
<a href="#how-it-works"><span>Как это работает</span></a>
|
||||
@@ -195,6 +192,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" aria-hidden="true" style="display:block" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.5 11.5 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="globe-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10"/></svg>
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
@@ -207,14 +214,9 @@
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="chevron-icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<nav class="sr-only" aria-label="Language alternatives">
|
||||
<a href="https://sync.koalastuff.net/" hreflang="en">English</a>
|
||||
<a href="https://sync.koalastuff.net/de/" hreflang="de">Deutsch</a>
|
||||
<a href="https://sync.koalastuff.net/fr/" hreflang="fr">Français</a>
|
||||
<a href="https://sync.koalastuff.net/es/" hreflang="es">Español</a>
|
||||
<a href="https://sync.koalastuff.net/pt-BR/" hreflang="pt-br">Português (Brasil)</a>
|
||||
<a href="https://sync.koalastuff.net/ru/" hreflang="ru">Русский</a>
|
||||
</nav>
|
||||
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -222,10 +224,10 @@
|
||||
<header class="hero" id="main-content">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/blob/main/CHANGELOG.md" target="_blank" class="version-badge" data-reveal style="text-decoration: none; display: inline-block; transition: transform 0.2s, box-shadow 0.2s;" title="View Changelog">
|
||||
<span class="version-text-en" lang="en">v1.5.3 OUT NOW</span>
|
||||
<span class="version-text-de" lang="de">v1.5.3 JETZT VERFÜGBAR</span>
|
||||
</div>
|
||||
</a>
|
||||
<h1 data-reveal>Смотрите вместе.<br>Синхронно на 100%.</h1>
|
||||
<p class="hero-subtitle" data-reveal>Ваш киновечер на расстоянии без задержек. Без регистрации и сбора данных. Просто поделитесь ссылкой и смотрите вместе.</p>
|
||||
<div class="hero-mascot-container" data-reveal>
|
||||
@@ -254,7 +256,7 @@
|
||||
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
|
||||
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
|
||||
<span>v1.9.3</span>
|
||||
<span class="mockup-version">2.0.5</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
@@ -517,7 +519,7 @@
|
||||
|
||||
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v1.9.3</div>
|
||||
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +764,16 @@
|
||||
<a href="https://www.teleparty.com/premium" target="_blank" rel="noopener noreferrer" class="source-link" title="See Teleparty Premium Platforms"><sup>[1]</sup></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat-name">
|
||||
<strong><span>Локализация / Перевод</span></strong>
|
||||
<span class="feat-desc"><span>Доступные языки интерфейса и поддержка перевода.</span></span>
|
||||
</td>
|
||||
<td class="check highlight">✔ <span>6 языков (и расширяется)</span></td>
|
||||
<td class="cross">
|
||||
✘ <span>Только английский</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footnotes">
|
||||
@@ -875,7 +887,7 @@
|
||||
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<div class="illus-popup-version">v1.9.3</div>
|
||||
<div class="illus-popup-version mockup-version">2.0.5</div>
|
||||
</div>
|
||||
<div class="illus-popup-tabs">
|
||||
<div class="illus-popup-tab active">
|
||||
@@ -1177,6 +1189,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.e6679b8d.min.js"></script>
|
||||
<script src="../app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.9.3",
|
||||
"date": "2026-05-30T00:01:24Z"
|
||||
"version": "2.0.5",
|
||||
"date": "2026-06-03T09:32:00Z"
|
||||
}
|
||||
|
||||