OpenTogetherTube provides two voting features: Vote Mode for democratic queue ordering and Vote-to-Skip for collective skip decisions.
Vote Mode
In Vote Mode, the queue automatically reorders based on user votes. The most voted videos play first, creating a democratic playlist.
How It Works
Enabling Vote Mode
Set the queue mode to vote:
PATCH / api / room / : name
{
"queueMode" : "vote"
}
Or via the room settings UI:
enum QueueMode {
Manual = "manual" , // Default: videos play in order added
Vote = "vote" , // Videos ordered by vote count
Loop = "loop" , // Videos re-queue after playing
Dj = "dj" // Current video restarts when finished
}
Voting on Videos
Users with the manage-queue.vote permission can vote:
Add Vote
POST /api/room/:name/vote
Content-Type : application/json
{
"service" : "youtube" ,
"id" : "dQw4w9WgXcQ"
}
Remove Vote
DELETE /api/room/:name/vote
Content-Type : application/json
{
"service" : "youtube" ,
"id" : "dQw4w9WgXcQ"
}
Implementation
Vote Storage
Votes are stored as a map of video IDs to client ID sets:
class Room {
// Map: "service+id" -> Set of client IDs who voted
votes : Map < string , Set < ClientId >> = new Map ();
// Computed vote counts for syncing to clients
get voteCounts () : Map < string , number > {
const counts = new Map ();
for ( const [ vid , votes ] of this . votes . entries ()) {
counts . set ( vid , votes . size );
}
return counts ;
}
}
Processing Vote Requests
public async vote (
request : VoteRequest ,
context : RoomRequestContext
): Promise < void > {
if (!context.clientId) {
throw new OttException ( "Can't vote if not connected to room." );
}
const key = request . video . service + request . video . id ;
if (this.votes.has( key )) {
const votes = this . votes . get ( key ) ! ;
if ( request . add ) {
votes . add ( context . clientId );
} else {
votes . delete ( context . clientId );
}
} else {
if ( request . add ) {
this . votes . set ( key , new Set ([ context . clientId ]));
}
}
this.markDirty( "voteCounts" );
}
Automatic Reordering
The queue reorders on every update cycle:
public async update (): Promise < void > {
// Sort queue according to queue mode
if (this.queueMode === QueueMode.Vote) {
await this . queue . orderBy (
[
video => {
const votes = this . votes . get ( video . service + video . id );
return votes ? votes . size : 0 ;
}
],
[ "desc" ] // Descending order (most votes first)
);
}
// ...
}
Vote Cleanup
When users leave, their votes are removed:
public async leaveRoom (
request : LeaveRequest ,
context : RoomRequestContext
): Promise < void > {
// ... other leave logic
if (this.queueMode === QueueMode.Vote) {
this . votes . forEach ( value => {
if ( value . delete ( removed . id )) {
this . markDirty ( "voteCounts" );
}
});
}
}
UI Integration
Vote counts are displayed in the queue:
< template >
< div class = "queue-item" >
< span > {{ video . title }} </ span >
< v-chip v-if = " voteCount > 0 " >
< v-icon : icon = " mdiThumbUp " />
{{ voteCount }}
</ v-chip >
</ div >
</ template >
< script setup >
const voteCount = computed (() => {
const key = props . video . service + props . video . id ;
return store . state . room . voteCounts . get ( key ) ?? 0 ;
});
</ script >
Vote-to-Skip
Vote-to-Skip allows users to collectively decide when to skip the current video.
How It Works
User Votes to Skip
User clicks the skip button (adds vote)
Threshold Check
Server checks if enough users voted (≥50% of eligible voters)
Auto-Skip
When threshold is met, video automatically skips
Reset
Vote counts reset when new video starts
Enabling Vote-to-Skip
PATCH / api / room / : name
{
"enableVoteSkip" : true
}
Or via the room settings UI:
< v-checkbox
v-model = " settings . enableVoteSkip . value "
: label = " $t ( 'room-settings.enable-vote-skip' ) "
: disabled = " ! granted ( 'configure-room.other' ) "
/>
Skip Threshold
The threshold is 50% of eligible voters:
// From common/voteskip.ts
export function voteSkipThreshold ( users : number ) : number {
return Math . ceil ( users * 0.5 );
}
export function countEligibleVoters (
users : RoomUserInfo [],
grants : Grants
) : number {
let count = 0 ;
for ( const user of users ) {
if ( grants . granted ( user . role , "playback.skip" )) {
count ++ ;
}
}
return count ;
}
Only users with the playback.skip permission count toward the threshold. This prevents abuse from users without skip permission.
Implementation
Vote Storage
class Room {
votesToSkip : Set < string > = new Set (); // Client IDs
enableVoteSkip : boolean = false ;
}
Skip Request Handling
public async skip (
request : SkipRequest ,
context : RoomRequestContext
): Promise < void > {
if (!this.currentSource) return;
let shouldSkip = false ;
if (this.enableVoteSkip) {
if ( context . clientId ) {
// Toggle vote
if ( this . votesToSkip . has ( context . clientId )) {
this . votesToSkip . delete ( context . clientId );
} else {
this . votesToSkip . add ( context . clientId );
}
this . markDirty ( "votesToSkip" );
}
// Check threshold
const eligibleUsers = countEligibleVoters (
this . realusers . map ( u => this . getUserInfo ( u . id )),
this . grants
);
if ( this . votesToSkip . size >= voteSkipThreshold ( eligibleUsers )) {
shouldSkip = true ;
}
} else {
// Instant skip if vote-to-skip is disabled
shouldSkip = true ;
}
if ( shouldSkip ) {
const current = this . currentSource ;
const prevPosition = this . realPlaybackPosition ;
counterMediaSkipped
. labels ({ service: this . currentSource . service })
. inc ();
this . dequeueNext ();
await this . publishRoomEvent ( request , context , {
video: current ,
prevPosition
});
this . videoSegments = [];
}
}
Vote Reset
Votes reset when a new video starts:
async dequeueNext () {
if ( this . enableVoteSkip ) {
this . votesToSkip . clear ();
this . markDirty ( "votesToSkip" );
}
// ... dequeue logic
}
Cleanup on Leave
Remove user’s skip vote when they disconnect:
public async leaveRoom (
request : LeaveRequest ,
context : RoomRequestContext
): Promise < void > {
// ... other leave logic
if (this.enableVoteSkip) {
this . votesToSkip . delete ( context . clientId );
this . markDirty ( "votesToSkip" );
}
}
UI Component
The VoteSkip.vue component displays the vote progress:
< template >
< v-banner
v-if = " store . state . room . enableVoteSkip && shouldShow "
class = "vote-skip-banner"
>
< template # text >
< v-progress-linear
: model-value = " votePercentage "
height = "30"
color = "primary"
>
{{ votesNeeded }} / {{ threshold }} votes to skip
</ v-progress-linear >
</ template >
</ v-banner >
</ template >
< script setup >
const votesNeeded = computed (() => {
return store . state . room . votesToSkip ?. size ?? 0 ;
});
const eligibleVoters = computed (() => {
return countEligibleVoters (
Array . from ( store . state . users . users . values ()),
store . state . room . grants
);
});
const threshold = computed (() => {
return voteSkipThreshold ( eligibleVoters . value );
});
const votePercentage = computed (() => {
return ( votesNeeded . value / threshold . value ) * 100 ;
});
</ script >
State Synchronization
Vote-to-skip state is synchronized to clients:
// In room.ts syncableProps
const syncableProps : ( keyof RoomStateSyncable )[] = [
// ... other props
"enableVoteSkip" ,
"votesToSkip" ,
];
Clients receive updates:
interface ServerMessageSync {
action : "sync" ;
enableVoteSkip ?: boolean ;
votesToSkip ?: Set < ClientId >;
// ...
}
Combining Vote Mode and Vote-to-Skip
Both features can be enabled simultaneously:
Vote Mode : Determines queue order based on votes
Vote-to-Skip : Requires collective decision to skip current video
This creates a fully democratic room where users vote on what to watch and when to skip.
Permissions
Voting requires specific permissions:
Feature Required Permission Default Access Vote on queue items manage-queue.voteUnregisteredUser Skip/vote-to-skip playback.skipUnregisteredUser
Room owners can restrict voting to registered or trusted users:
// Only let registered users vote
grants . setRoleGrants ( Role . UnregisteredUser , parseIntoGrantMask ([
"playback" ,
"manage-queue.add" ,
// Omit manage-queue.vote
]));
Best Practices
Start with Vote Mode
Enable vote mode for community rooms to give everyone a voice.
Add Vote-to-Skip
Enable vote-to-skip to prevent one person from holding the room hostage.
Adjust Permissions
Restrict voting to registered users if spam becomes an issue.
Monitor Usage
Check vote patterns to understand your community’s preferences.
Limitations
Vote data is not persisted across room unloads. When a room unloads, all votes are lost.
Vote-to-skip votes are per-video. They reset when the video changes, not when users change their vote.
API Reference
Vote on Queue Item
POST /api/room/:name/vote
DELETE /api/room/:name/vote
Content-Type : application/json
{
"service" : "youtube" | "vimeo" | "dailymotion" | "direct" ,
"id" : "video_id"
}
Response:
Skip Request (WebSocket)
// Client -> Server
{
action : "req" ,
request : {
type : RoomRequestType . SkipRequest
}
}
// Server -> All Clients (if skip succeeds)
{
action : "event" ,
request : { type : RoomRequestType . SkipRequest },
user : { id : "..." , name : "..." , ... },
additional : {
video : { service : "youtube" , id : "..." , ... },
prevPosition : 42.5
}
}
Room Management Queue modes and room configuration
Permissions Control who can vote and skip
Video Sync How skip events synchronize playback